Mert Özen Her Satırda Daha İleri
DevOps Web Server'lar ve Hosting — #11

Hosting Seçimi ve Production Checklist'i: Nginx, IIS, Caddy, Container ve PaaS

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
Hosting Seçimi ve Production Checklist'i: Nginx, IIS, Caddy, Container ve PaaS

Hosting seçimini önce ekibin neyi işletebildiği, sonra servis sayısı, deploy sıklığı ve maliyet belirler; Linux VM ve Nginx, IIS, Caddy, Traefik, YARP, PaaS ve container aynı soruların farklı cevapları. Caddy'yi yedi satırlık config ve active health check ile deniyor, go-live öncesi on maddelik bir kontrol script'iyle yedinci yazının config'inde dışarıdan görünmeyen health endpoint'i ve 443'te eksik default server'ı yakalıyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (ASP.NET Core runtime 10.0.14), Nginx 1.30.6, Caddy 2.11.9 ve curl 8.23 ile Docker 29 üzerinde denenmiştir; IIS (Internet Information Services), Traefik ve bulut servisleri bu yazı için çalıştırılmadı.

Önce Tahmin Et

Yedinci yazının sonunda Bookstore'un Nginx config'i hazırdı: TLS (Transport Layer Security), HTTP (Hypertext Transfer Protocol) 2 ve 3, security header'ları, rate limiting, üç Kestrel arasında load balancing ve passive health check. Go-live'dan önce on maddelik bir kontrol script'i yazdım ve o config'e karşı çalıştırdım.

Tahmin et: on maddenin kaçı geçer? Yedi yazı boyunca her parçayı tek tek denedik; gözden kaçan bir şey kalmış olabilir mi?

Seçenekler: Kim Neyi Üstleniyor?

Serinin ilk yazısındaki rol ayrımı burada karar aracına dönüşüyor. Her hosting seçeneği aynı soruların farklı cevapları: TLS'i kim sonlandırıyor, certificate'ı kim yeniliyor, process'i kim ayakta tutuyor, yükü kim dağıtıyor, sağlıksız instance'ı kim çıkarıyor?

SeçenekTLS ve certificateProcess yönetimiLoad balancing ve healthKimin için
Linux VM (virtual machine) + Nginx + systemdNginx; yenileme certbot ya da ACME (Automatic Certificate Management Environment) modülü ilesystemdupstream; passive checkLinux bilen ekip, tam kontrol isteyen, birkaç sunucu
Windows + IIS + ANCM (ASP.NET Core Module)IIS binding, Windows certificate storeIIS ve ANCMAyrı bir load balancer ya da ARR (Application Request Routing)Windows altyapısı ve Windows authentication kullanan kurumlar
CaddyOtomatik; edinme ve yenileme dahilsystemd ya da containerreverse_proxy; active health checkAz config ile hızlı ve güvenli başlangıç
TraefikOtomatik (ACME)Container platformuContainer label'larından keşifDocker ve Kubernetes'te dinamik routing
YARP (Yet Another Reverse Proxy)KestrelSenin .NET uygulamanKodla ve config ileProxy'ye .NET kodu eklemek isteyenler, API (Application Programming Interface) gateway
PaaS (Platform as a Service): Azure App Service vb.PlatformPlatformPlatform; scale-out ayarıAltyapıyla uğraşmak istemeyen küçük ekipler
Container + KubernetesIngress ya da Gateway APIKubernetesService ve probe'larÇok servisli, sık deploy eden, platform ekibi olan organizasyonlar

Tablodaki sağ sütun en önemlisi: doğru seçim çoğu zaman teknik değil, ekiple ilgili. Nginx config'ini okuyabilen kimse yoksa en iyi Nginx kurulumu bile bir gece arızasında kara kutu. Aynı şekilde yalnız Windows yöneten bir operasyon ekibine Linux VM'i devretmek, sorunu taşımaktan ibaret.

Caddy: Otomatik HTTPS ile En Kısa Yol

Caddy, varsayılanları güvenli olan bir web server ve reverse proxy: bir domain adı verdiğinde certificate'ı kendisi alıyor, yeniliyor ve HTTP'yi HTTPS'e (HTTP Secure) kendisi yönlendiriyor. Bookstore'un iki Kestrel instance'ının önüne koyduğum Caddyfile'ın tamamı:

bookstore.test {
    tls internal
    reverse_proxy 172.30.85.21:8080 172.30.85.22:8080 {
        lb_policy round_robin
        health_uri /health
        health_interval 5s
    }
}

tls internal, Caddy'nin kendi yerel CA'sından (Certificate Authority) certificate üretmesini söylüyor; .test gibi internete açık olmayan bir domain için gerekli. Gerçek bir domain'de bu satırı silersen Caddy Let's Encrypt ya da ZeroSSL'den certificate alıyor. Başlangıç log'u:

{"level":"info","ts":1794113277.2935407,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"bookstore.test","issuer":"local"}

Davranışı:

$ curl -s -o /dev/null -D - http://bookstore.test/api/books | grep -E "^HTTP|Location"
HTTP/1.1 308 Permanent Redirect
Location: https://bookstore.test/api/books
$ curl -s -o /dev/null -D - https://bookstore.test/api/books | grep -i -E "^HTTP|server|alt-svc|via"
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
server: Kestrel
via: 1.1 Caddy
$ for i in 1 2 3 4; do curl -s https://bookstore.test/api/whoami | jq -c "{instance,remoteIp,scheme,host}"; done
{"instance":"api1","remoteIp":"172.30.85.50","scheme":"https","host":"bookstore.test"}
{"instance":"api2","remoteIp":"172.30.85.50","scheme":"https","host":"bookstore.test"}
{"instance":"api1","remoteIp":"172.30.85.50","scheme":"https","host":"bookstore.test"}
{"instance":"api2","remoteIp":"172.30.85.50","scheme":"https","host":"bookstore.test"}

Yedi satırlık config ile HTTP'den HTTPS'e yönlendirme, HTTP/2, HTTP/3 (alt-svc), round robin ve doğru forwarded header'lar geldi; uygulama client IP'sini (Internet Protocol adresi) ve https'i gördü (Kestrel tarafında üçüncü yazıdaki KnownIPNetworks ayarı yine gerekiyor). İki fark dikkat çekiyor. Caddy upstream'in Server: Kestrel header'ını olduğu gibi geçiriyor; gizlemek istiyorsan bunu config'te yapman ya da Kestrel'de AddServerHeader = false demen gerekiyor. İkincisi, Caddy açık kaynak Nginx'te olmayan active health check yapıyor; api2'yi durdurduğumda:

{"level":"info","ts":1794113300.2571716,"logger":"http.handlers.reverse_proxy.health_checker.active","msg":"HTTP request failed","host":"172.30.85.22:8080","error":"Get \"http://172.30.85.22:8080/health\": dial tcp 172.30.85.22:8080: i/o timeout"}
api1 200
api1 200
api1 200
api1 200

Caddy beş saniyede bir /health'i çağırıp sağlıksız instance'ı kendisi çıkardı; gerçek istekler ona hiç gitmedi. ASP.NET Core Web API (Application Programming Interface) serisinin "Health Check" yazısındaki readiness endpoint'i burada doğrudan iş görüyor. Caddy'nin bedeli: ekosistemi Nginx'inkinden küçük, ileri senaryolarda (karmaşık cache kuralları, njs gibi script modülleri) seçenek daha az ve ekibin çoğu onu ilk kez görecek.

Traefik, YARP, PaaS ve Container

Traefik, config'ini dosyadan çok çalışan platformdan alan bir proxy: Docker'da container label'larından, Kubernetes'te Ingress ve Gateway API kaynaklarından route'ları keşfediyor. Container sayısı ve adresleri sürekli değişen ortamlarda güçlü; tek bir VM'deki iki Kestrel için gereğinden karmaşık. Bu yazı için çalıştırmadım; ilerideki Docker ve Kubernetes serileri container tarafını ele alacak.

YARP, Microsoft'un .NET kütüphanesi olarak yazdığı reverse proxy. Proxy'nin kendisi bir ASP.NET Core uygulaması oluyor: routing kurallarını config'ten okuyabiliyor, araya middleware ekleyebiliyor, authentication'ı ve rate limiting'i .NET koduyla yapabiliyorsun. Önde yine TLS'i sonlandıran bir şey (Kestrel'in kendisi ya da bir load balancer) var; YARP daha çok API gateway ve backend-for-frontend rolünde. Ayrıntılar ilerideki API Gateway ve YARP serisinde.

PaaS'ta, örneğin Azure App Service'te, bu serinin dört ile onuncu yazıları arasındaki işlerin çoğunu platform yapıyor: TLS, certificate yenileme, load balancing, process'in yeniden başlatılması, deployment slot'larıyla kesintisiz deploy. Karşılığında platformun sınırlarını kabul ediyorsun: timeout'lar, bağlantı limitleri, dosya sistemi davranışı ve fiyatlandırma senin kontrolünde değil. Forwarded headers ve Data Protection gibi konular PaaS'ta da kaybolmuyor, yalnız platformun belgelediği biçimde çözülüyor.

Karar Vermek: Dört Soru

Bookstore için seçim yaparken şu dört soruyu sırayla sordum:

  • Ekip neyi işletebiliyor? Gece 03:00'te arızayı kim, hangi log'a bakarak çözecek? Cevap Windows ise IIS, Linux ise Nginx ya da Caddy, ikisi de değilse PaaS.
  • Kaç servis, kaç instance, ne sıklıkla deploy? Bir API ve bir Angular uygulaması için bir VM ve Nginx yeterli; on servis ve günde birkaç deploy, container ve orkestrasyonu haklı çıkarıyor.
  • Kesintisiz deploy şart mı? Tek instance'lı VM'de restart kısa bir kesinti; şartsa ya iki instance ve drain ya da deployment slot'ları olan bir platform.
  • Maliyetin hangi kısmı önemli? VM ucuz ama işletme saati pahalı; PaaS'ta durum tersine dönebiliyor. Ölçek büyüdükçe hesap değişiyor.

Bookstore'un cevabı: Linux VM, Nginx, systemd altında iki Kestrel instance'ı ve Nginx'ten sunulan Angular. Bu seri tam olarak bunu kurdu. Ekip büyüyüp servis sayısı arttığında aynı uygulama değişmeden container'a taşınabilir: Kestrel 8080'i dinliyor, forwarded headers configuration'dan, Data Protection key'leri paylaşılan bir depoda.

Production Checklist'i: Kontrol Script'i

Seçim ne olursa olsun go-live'dan önce aynı soruları kontrol etmek gerekiyor ve bunları kontrol listesinde işaretlemek yerine bir script'le test etmek daha güvenilir. Bookstore için yazdığım script, client'ın gördüğü yüzü dışarıdan kontrol ediyor:

#!/bin/sh
# prodcheck.sh <host> <proxy-ip> <ca-file>: go-live öncesi smoke kontrolleri
H=$1; IP=$2; CA=$3
R="--cacert $CA --resolve $H:443:$IP --resolve $H:80:$IP"
ok() { printf "PASS  %s\n" "$1"; }
bad() { printf "FAIL  %s (%s)\n" "$1" "$2"; }
hdr=$(curl -s -o /dev/null -D - $R "https://$H/api/books")
code=$(curl -s -o /dev/null -w "%{http_code}" $R "https://$H/health")
[ "$code" = 200 ] && ok "health endpoint 200" || bad "health endpoint 200" "$code"
loc=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" $R "http://$H/api/books")
case "$loc" in 30[18]\ https://*) ok "http redirects to https";; *) bad "http redirects to https" "$loc";; esac
n=$(echo "$hdr" | grep -ci "^strict-transport-security")
[ "$n" = 1 ] && ok "exactly one HSTS header" || bad "exactly one HSTS header" "$n"
echo "$hdr" | grep -qi "^x-content-type-options: nosniff" && ok "nosniff" || bad "nosniff" "missing"
srv=$(echo "$hdr" | grep -i "^server:" | tr -d '\r')
echo "$srv" | grep -q "[0-9]" && bad "server header hides version" "$srv" || ok "server header hides version ($srv)"
echo | openssl s_client -connect "$IP:443" -servername "$H" -tls1_1 -cipher "DEFAULT@SECLEVEL=0" >/dev/null 2>&1 && bad "TLS 1.1 rejected" "accepted" || ok "TLS 1.1 rejected"
who=$(curl -s $R "https://$H/api/whoami")
echo "$who" | grep -q '"scheme":"https"' && ok "app sees https" || bad "app sees https" "$who"
rip=$(echo "$who" | jq -r .remoteIp)
case "$rip" in *"$IP") bad "app sees client IP" "$rip";; *) ok "app sees client IP ($rip)";; esac
code=$(head -c 2000000 /dev/zero | curl -s -o /dev/null -w "%{http_code}" $R -H "Content-Type: application/octet-stream" --data-binary @- "https://$H/api/uploads")
[ "$code" = 413 ] && ok "body limit enforced at proxy (2 MB -> 413)" || bad "body limit enforced at proxy" "$code"
code=$(curl -s -o /dev/null -w "%{http_code}" $R -H "Host: evil.example" "https://$IP/api/books" -k)
[ "$code" = 200 ] && bad "unknown host rejected" "$code" || ok "unknown host rejected ($code)"

Yedinci yazının config'ine karşı, tahminin cevabı:

$ ./prodcheck.sh bookstore.test 172.30.85.10 ca.crt
FAIL  health endpoint 200 (404)
PASS  http redirects to https
PASS  exactly one HSTS header
PASS  nosniff
PASS  server header hides version (server: nginx)
PASS  TLS 1.1 rejected
PASS  app sees https
PASS  app sees client IP (172.30.85.50)
PASS  body limit enforced at proxy (2 MB -> 413)
FAIL  unknown host rejected (200)

İki madde kaldı ve ikisi de parçalar tek tek doğruyken bütünde ortaya çıkan türden. Health endpoint Kestrel'de vardı ama Nginx yalnız /api/'yi iletiyordu; dışarıdan bakan bir load balancer ya da deploy script'i /health'e ulaşamıyor. 443'te de bir default_server yoktu, bu yüzden IP adresiyle ve rastgele bir Host ile gelen istek dosyadaki ilk server'a, yani Bookstore'a düştü; dördüncü yazıdaki 444'lü default server yalnız 80 portundaydı. İki ekleme:

# bilinmeyen host adları için TLS el sıkışmasını reddet
server {
    listen 443 ssl default_server;
    ssl_reject_handshake on;
}

    # load balancer ve deploy script'i için health endpoint
    location = /health {
        proxy_pass http://bookstore_api;
        allow 172.30.85.0/24;
        deny all;
    }
$ ./prodcheck.sh bookstore.test 172.30.85.10 ca.crt
PASS  health endpoint 200
PASS  http redirects to https
PASS  exactly one HSTS header
PASS  nosniff
PASS  server header hides version (server: nginx)
PASS  TLS 1.1 rejected
PASS  app sees https
PASS  app sees client IP (172.30.85.50)
PASS  body limit enforced at proxy (2 MB -> 413)
PASS  unknown host rejected (000)

ssl_reject_handshake on, tanımadığı bir SNI (Server Name Indication) ile gelen TLS bağlantısını certificate göndermeden kesiyor; 80 portundaki 444'ün HTTPS karşılığı. /health'i yalnız iç ağa açtım: health endpoint'i uygulamanın iç durumunu anlatabilir ve herkese açık olması gerekmiyor. Script ilk yazdığımda da bir hata yapmıştı: client IP kontrolünü yanıtın tamamında arıyordu ve proxy'nin adresi xOriginalFor alanında geçtiği için yanlış alarm verdi. Kontrol script'leri de kod; yanlış pozitifleri ve negatifleri test edilmeli.

Script'in Kontrol Edemediği Maddeler

Dışarıdan bir curl her şeyi göremiyor. Bookstore'un go-live listesinde script'in yanında elle ya da başka araçlarla kontrol edilen maddeler:

  • Graceful shutdown: KillSignal=SIGTERM (terminate sinyali), yeterli TimeoutStopSec, açık istek varken systemctl stop denemesi (sekizinci yazı).
  • Kestrel'in yalnız loopback'i ya da Unix socket'i dinlediği, proxy'nin atlanamadığı (ikinci yazı).
  • Forwarded headers'ta güvenilir listenin yalnız gerçek proxy'leri içerdiği; ASPNETCORE_FORWARDEDHEADERS_ENABLED'ın açık olmadığı (üçüncü yazı).
  • Data Protection key'lerinin kalıcı ve instance'lar arasında paylaşılan bir yerde, şifreli olarak durduğu (üçüncü ve onuncu yazılar).
  • Limit'lerin hizalı olduğu: Nginx client_max_body_size, Kestrel MaxRequestBodySize, header buffer'ları, timeout'lar (beşinci yazı).
  • Log'ların merkezi bir yere gittiği ve bir isteğin proxy ile uygulama arasında correlation ID ile izlenebildiği.
  • Monitoring ve alert'ler: 5xx oranı, upstream süresi, restart sayısı, certificate'ın bitiş tarihi. Ayrıntılar ilerideki Observability serisinde.
  • Yedek ve geri dönüş: önceki release diskte hazır, geri dönüş komutu denenmiş, veritabanı yedeği alınmış ve geri yüklemesi en az bir kez test edilmiş.
  • Runtime ve proxy güncellemeleri için bir takvim: .NET servis sürümleri ve Nginx stable dalının güvenlik yamaları.

Üretimde Dikkat

Kontrol script'ini bir kez çalıştırıp bırakma; deploy pipeline'ının son adımı yap. Her config değişikliği, birinin "küçük bir düzeltme" diye eklediği bir location dahil, aynı on maddeden geçsin. Pipeline'da başarısız bir madde deploy'u geri almalı.

Seçimi belgeyle. Neden Nginx, neden VM, neden PaaS değil: bu kararın bir ADR'de (Architecture Decision Record) iki paragraflık gerekçesi, iki yıl sonra "neden böyle yapmışız" sorusunu cevaplıyor. Architecture Decision'lar serisi bunun nasıl yazılacağını anlatıyor.

Sık Düşülen Tuzaklar

Seçimi teknolojiye göre yapmak. En hızlı proxy, ekibin işletemediği proxy'den daha iyi değil.

Health endpoint'in proxy'den görünmediğini fark etmemek. Uygulamada vardır, test edilmiştir, ama load balancer ona ulaşamıyorsa sağlıksız instance trafiği almaya devam eder.

443 için default server tanımlamamak. 80'deki 444'ü yazan ekip HTTPS'i unutuyor; IP ile tarayan bot'lar ve yanlış yönlenmiş domain'ler uygulamaya ulaşıyor.

Checklist'i bir kez doldurup unutmak. Config değişiyor, liste aynı kalıyor; kontrolü otomatikleştir.

PaaS'ın her şeyi çözdüğünü düşünmek. Forwarded headers, Data Protection, graceful shutdown ve limit'ler platformda da geçerli; yalnız ayarlandıkları yer değişiyor.

Küçük Bir Deneme

★☆☆ prodcheck.sh'yi Caddy kurulumuna karşı çalıştır. Hangi maddeler geçiyor, hangileri kalıyor? Kalanları Caddyfile'da nasıl düzeltirsin?

★★☆ Script'e iki madde ekle: certificate'ın bitiş tarihine 30 günden az kaldıysa FAIL, ve 30 isteği aynı anda gönderince en az birinin 429 aldığı. İkinci madde production'da çalıştırılırsa ne olur, nasıl güvenli hâle getirirsin?

★★★ Bookstore için kendi karar tablonu doldur: ekibinin bugünkü yetkinliği, servis sayısı, deploy sıklığı ve bütçe. Sonucu bir ADR olarak yaz ve seçmediğin iki seçenek için "hangi koşulda bu karar değişir" bölümünü doldur.

Serinin son yazısında zamanda geriye bakıyoruz: .NET 6'dan .NET 10'a hosting'de ne değişti? Container'larda 80'den 8080'e geçiş, ASPNETCORE_HTTP_PORTS, non-root kullanıcı, Ubuntu tabanlı image'lar, KnownIPNetworks, eski tutorial'lardaki tuzaklar ve .NET 11 ile gelenler.