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çenek | TLS ve certificate | Process yönetimi | Load balancing ve health | Kimin için |
|---|---|---|---|---|
| Linux VM (virtual machine) + Nginx + systemd | Nginx; yenileme certbot ya da ACME (Automatic Certificate Management Environment) modülü ile | systemd | upstream; passive check | Linux bilen ekip, tam kontrol isteyen, birkaç sunucu |
| Windows + IIS + ANCM (ASP.NET Core Module) | IIS binding, Windows certificate store | IIS ve ANCM | Ayrı bir load balancer ya da ARR (Application Request Routing) | Windows altyapısı ve Windows authentication kullanan kurumlar |
| Caddy | Otomatik; edinme ve yenileme dahil | systemd ya da container | reverse_proxy; active health check | Az config ile hızlı ve güvenli başlangıç |
| Traefik | Otomatik (ACME) | Container platformu | Container label'larından keşif | Docker ve Kubernetes'te dinamik routing |
| YARP (Yet Another Reverse Proxy) | Kestrel | Senin .NET uygulaman | Kodla ve config ile | Proxy'ye .NET kodu eklemek isteyenler, API (Application Programming Interface) gateway |
| PaaS (Platform as a Service): Azure App Service vb. | Platform | Platform | Platform; scale-out ayarı | Altyapıyla uğraşmak istemeyen küçük ekipler |
| Container + Kubernetes | Ingress ya da Gateway API | Kubernetes | Service 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
drainya 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), yeterliTimeoutStopSec, açık istek varkensystemctl stopdenemesi (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, KestrelMaxRequestBodySize, 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.