Nginx'te HTTPS, Security Header'ları, Rate Limiting ve Load Balancing
Nginx'te TLS termination, http2 on ve listen quic ile HTTP/3, add_header_inherit merge ile kaybolmayan security header'ları, tek yerden HSTS, limit_req ile 429 ve upstream zone'u olmadan round robin'in dengesiz dağıldığı gerçek koşularla. ip_hash, 1.29.6 ile gelen sticky cookie ve max_fails ile passive health check: bir instance ölünce client tek hata görmüyor, ama idempotent olmayan istekler ve sağlıksız ama ayakta process'ler ayrı ele alınmalı.
Sürüm notu: Bu yazıdaki örnekler Nginx 1.30.6 (OpenSSL 3.5.7 ile derlenmiş resmi image), .NET 10 (ASP.NET Core runtime 10.0.14), libmsquic 2.6.3 ve curl 8.23 ile Docker 29 üzerinde denenmiştir; certificate'lar çalışma klasöründe üretilmiş test certificate'larıdır.
Önce Tahmin Et
Bookstore.Api artık üç Kestrel instance'ında çalışıyor ve önlerinde tek bir Nginx var. upstream block'unda üç server satırı var, yük dağıtma yöntemi belirtilmemiş, yani varsayılan round robin. Client art arda 12 istek gönderiyor ve her yanıtta hangi instance'ın cevap verdiğine bakıyor.
Tahmin et: her instance'a 4 istek mi düşer? Sonra instance'lardan birinin process'i ölüyor; 12 isteğin kaçı hata alır?
TLS Termination: Certificate'ı Nginx'e Takmak
Bookstore'un HTTPS (HTTP Secure) config'inin tamamı:
limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
upstream bookstore_api {
server 172.30.85.21:8080 max_fails=3 fail_timeout=30s;
server 172.30.85.22:8080 max_fails=3 fail_timeout=30s;
server 172.30.85.23:8080 max_fails=3 fail_timeout=30s;
}
server {
listen 80;
server_name bookstore.test;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen 443 quic reuseport;
http2 on;
server_name bookstore.test;
ssl_certificate /certs/bookstore.test.crt;
ssl_certificate_key /certs/bookstore.test.key;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location /api/ {
limit_req zone=api_per_ip burst=10 nodelay;
limit_req_status 429;
limit_conn conn_per_ip 20;
proxy_pass http://bookstore_api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# kendi add_header'ı olan bir location
location = /favicon.ico {
root /srv/bookstore-web;
add_header Cache-Control "public, max-age=86400";
}
}
TLS (Transport Layer Security) burada bitiyor; Kestrel'lere düz HTTP (Hypertext Transfer Protocol) gidiyor ve onlar da üçüncü yazıdaki gibi forwarded headers'la isteğin HTTPS geldiğini öğreniyor. 80 portundaki server, her şeyi 443'e yönlendiriyor:
$ curl -s -o /dev/null -D - http://bookstore.test/api/books | grep -E "^HTTP|Location|Server"
HTTP/1.1 301 Moved Permanently
Server: nginx/1.30.6
Location: https://bookstore.test/api/books
ssl_protocols TLSv1.2 TLSv1.3 zaten Nginx'in 1.27.3'ten beri varsayılanı (TLS 1.0 ve 1.1 o sürümde varsayılan olarak kapatıldı), ama açıkça yazmak config'i okuyanın işini kolaylaştırıyor. Üç sürümle el sıkışma:
tls1_1 (SECLEVEL=0): 20DD72A0FFFF0000:error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:ssl/record/rec_layer_s3.c:918:SSL alert number 70
tls1_2: New, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
tls1_3: New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
TLS 1.1 denemesinde OpenSSL client'ının kendi güvenlik seviyesini SECLEVEL=0 ile düşürmem gerekti; yoksa client "no protocols available" deyip hiç bağlanmıyor ve test server hakkında bir şey söylemiyor. Server'ın reddettiğini "protocol version" alert'i gösteriyor. Cipher listesini elle yazmak yerine Mozilla'nın SSL (Secure Sockets Layer) Configuration Generator'ının "intermediate" profilinden başla; Microsoft'un dokümanı da oraya yönlendiriyor.
Certificate'ın nereden geldiği bu serinin konusu değil. Let's Encrypt ile otomatik edinme ve yenileme için klasik yol certbot; Nginx'in kendi ngx_http_acme_module'ü de artık resmi image'ta modül olarak geliyor. İkisi de, yenileme takvimi ve certificate chain'i ilerideki SSL/TLS Certificate'ları ve HTTPS serisinde.
HTTP/2 ve HTTP/3
Yukarıdaki config'te HTTP/2'yi http2 on; açıyor. Eski dokümanlarda, Microsoft'un örnek nginx.conf'u dahil, listen 443 ssl http2; yazıyor; 1.30'da bu satır çalışıyor ama uyarı veriyor:
nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead in /etc/nginx/conf.d/old.conf:2
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
HTTP/3 için ikinci bir listen satırı (443 quic reuseport) UDP (User Datagram Protocol) 443'ü açıyor, Alt-Svc header'ı da tarayıcıya "bir sonraki isteği QUIC (HTTP/3'ün altındaki UDP tabanlı transport) ile dene" diyor. Resmi image --with-http_v3_module ile derlenmiş, ek bir şey kurmak gerekmiyor. İkinci yazıdaki .NET HttpClient uygulamasıyla:
$ dotnet H3Client.dll https://bookstore.test/api/whoami /certs/ca.crt
QuicConnection.IsSupported: True
200 HTTP/3.0
{"instance":"api1","remoteIp":"172.30.85.51","remotePort":0,"connectionId":"0HNPTA10K1JVH","protocol":"HTTP/1.1","scheme":"https","host":"bookstore.test","pathBase":"","path":"/api/whoami","xForwardedFor":"","xOriginalFor":"[::ffff:172.30.85.10]:39198","xForwardedProto":"","link":"https://bookstore.test/api/books/1"}
172.30.85.51 - - [08/Nov/2026:04:25:11 +0000] "GET /api/whoami HTTP/3.0" 200 321 "-" "-" "-"
Client ile Nginx arasında HTTP/3, Nginx ile Kestrel arasında HTTP/1.1. QUIC'i Nginx'te sonlandırmak, Kestrel tarafında libmsquic ve UDP ayarlarıyla uğraşmamak demek.
Güncel kalmak burada özellikle önemli. HTTP/3 ve QUIC kodu Nginx'in en genç ve en hızlı değişen parçası; 1.30 dalının 2026'daki sürüm notlarında QUIC bağlantı taşımasıyla adres sahteciliği (CVE-2026-40460, 1.30.1), OpenSSL 3.5.0 ve öncesiyle HTTP/3 kullanırken heap overflow (CVE-2026-90439, 1.30.5) ve HTTP/3'te bilinmeyen bir frame'in request body sayılıp client_max_body_size'ın aşılabilmesi (CVE-2026-95101, 1.30.6) var. CVE (Common Vulnerabilities and Exposures) listesini takip etmiyorsan HTTP/3'ü açma; açtıysan stable dalın her yamasını uygula.
Security Header'ları ve add_header Mirası
Config'teki dört add_header satırının işleri: Strict-Transport-Security tarayıcıya bu domain'e bir yıl boyunca yalnız HTTPS ile gelmesini söylüyor, X-Content-Type-Options: nosniff tarayıcının içerik tipini tahmin etmesini engelliyor, Content-Security-Policy: frame-ancestors 'none' sayfanın başka bir sitenin iframe'inde açılmasını (clickjacking) engelliyor. always parametresi header'ın 4xx ve 5xx yanıtlarına da eklenmesini sağlıyor; yoksa yalnız 200, 201, 204, 206, 301, 302, 303, 304, 307 ve 308'e ekleniyor. server_tokens off da Server header'ından sürümü siliyor.
$ curl -s -o /dev/null -D - https://bookstore.test/api/books
HTTP/2 200
server: nginx
date: Sun, 08 Nov 2026 04:24:35 GMT
content-type: application/json; charset=utf-8
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
content-security-policy: frame-ancestors 'none'
alt-svc: h3=":443"; ma=86400
301 yanıtında ise Server: nginx/1.30.6 yazıyordu: server_tokens off'u yalnız 443'teki server block'una koydum. Sürüm bilgisini her yerden silmek istiyorsan directive'i http context'ine yaz. Şimdi kendi add_header'ı olan location:
$ curl -s -o /dev/null -D - https://bookstore.test/favicon.ico | grep -i -E "^HTTP|strict|nosniff|security-policy|cache-control|alt-svc"
HTTP/2 200
cache-control: public, max-age=86400
Dört güvenlik header'ının hepsi kayboldu. Dördüncü yazıda söz ettiğimiz kural bu: bir location'da tek bir add_header bile tanımlarsan üst context'tekiler o location'a miras kalmıyor. Altıncı yazıdaki cache header'lı location'lar da aynı durumda. 1.29.3'ten beri bunun bir çözümü var:
location = /favicon.ico {
root /srv/bookstore-web;
add_header_inherit merge;
add_header Cache-Control "public, max-age=86400";
}
HTTP/2 200
cache-control: public, max-age=86400
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
content-security-policy: frame-ancestors 'none'
alt-svc: h3=":443"; ma=86400
add_header_inherit merge üst seviyedeki header'ları bu seviyedekilerin yanına ekliyor. Directive'in kendisi de miras kaldığı için http ya da server seviyesine bir kez yazmak bütün location'lar için yetiyor. 1.29.3'ten eski sürümlerde tek yol, ortak header'ları bir dosyaya koyup her location'da include etmek.
HSTS Nerede Verilmeli?
Üçüncü yazıda Kestrel'de UseHsts ile HSTS (HTTP Strict Transport Security) header'ı ürettik. Aynı uygulamayı bu Nginx'in arkasına koyunca:
$ curl -s -o /dev/null -D - https://bookstore.test/api/books | grep -i strict
strict-transport-security: max-age=2592000
strict-transport-security: max-age=31536000
İki header, iki farklı süre: biri ASP.NET Core'un varsayılan 30 günü, diğeri Nginx'in bir yılı. RFC (Request for Comments) 6797'ye göre tarayıcı birden fazla HSTS header'ı gördüğünde yalnız ilkini dikkate alıyor; hangisinin önce geldiği de header'ları kimin ne sırayla eklediğine bağlı. Bu yüzden HSTS tek bir yerden verilmeli. TLS'i Nginx bitiriyorsa HSTS de Nginx'te dursun: Angular'ın statik dosyaları da dahil bütün yanıtlara tek bir kuralla eklenir ve uygulamanın scheme'i doğru görüp görmemesine bağlı kalmaz. Uygulamada UseHsts'i kaldır ya da yalnız Nginx'siz ortamlarda çalıştır. HSTS'i ilk kez açıyorsan kısa bir max-age ile başla; uzun bir değeri geri almak, tarayıcılardaki kaydın süresinin dolmasını beklemek demek.
Rate Limiting ve Bağlantı Limiti
limit_req_zone, client IP'sine ($binary_remote_addr) göre saniyede 5 isteklik bir bütçe tanımlıyor; burst=10 nodelay bunun üstüne 10 isteklik bir ani artışa bekletmeden izin veriyor. 30 isteği aynı anda gönderdim:
$ seq 1 30 | xargs -P 30 -I{} curl -s -o /dev/null -w "%{http_code}\n" https://bookstore.test/api/books | sort | uniq -c
11 200
19 429
[error] 23#23: *22 limiting requests, excess: 10.820 by zone "api_per_ip", client: 172.30.85.50, server: bookstore.test, request: "GET /api/books HTTP/2.0", host: "bookstore.test"
11 istek geçti: bir tane normal bütçeden, on tane burst'ten. Gerisi 429 (Too Many Requests) aldı; limit_req_status 429 olmasaydı Nginx'in varsayılanı 503'tü ve monitoring'de gerçek bir arızayla karışırdı. limit_conn conn_per_ip 20 ise aynı IP'nin (Internet Protocol) eşzamanlı bağlantı sayısını sınırlıyor; yavaş bağlantılarla sunucuyu meşgul eden saldırılara karşı ilk savunma.
İki uyarı. IP bazlı limit, NAT (Network Address Translation) arkasındaki bir şirketin bütün çalışanlarını tek client sayar; limitleri gerçek trafikten ölçerek koy. Önünde bir CDN (Content Delivery Network) ya da cloud load balancer varsa $binary_remote_addr onların adresi olur; gerçek client IP'sini ngx_http_realip_module'ün set_real_ip_from ve real_ip_header directive'leriyle, yalnız güvendiğin adreslerden alarak çıkarmalısın. Üçüncü yazıdaki KnownIPNetworks kuralının Nginx'teki karşılığı bu. ASP.NET Core'un kendi rate limiter'ı da var; ASP.NET Core Web API (Application Programming Interface) serisinin ".NET 7 ile ASP.NET Core Yenilikleri" yazısında. Kaba trafiği Nginx'te, kullanıcıya ya da plana göre limitleri uygulamada uygulamak iyi bir bölüşüm.
Load Balancing: Round Robin, least_conn, ip_hash ve sticky
Tahminin cevabı, ilk denemede beklenmedik çıktı. Aynı upstream'e, yalnız yöntemi değiştirerek 12'şer istek gönderdim:
RR without zone: 10 api1 1 api2 1 api3
RR with zone: 4 api1 4 api2 4 api3
ip_hash: 12 api2
least_conn: 4 api1 4 api2 4 api3
İlk satırda istekler eşit dağılmadı. Sebep worker process'ler: her worker kendi round robin sayacını tutuyor ve art arda gelen istekler farklı worker'lara düştüğünde her biri listeye baştan başlıyor. upstream'e zone bookstore_api 64k; ekleyince sayaç ve sunucuların durumu shared memory'ye taşınıyor, bütün worker'lar aynı sırayı paylaşıyor ve dağılım 4/4/4 oluyor. Zone, passive health check'in durumunun da worker'lar arasında paylaşılmasını sağlıyor; birden fazla server'lı her upstream'e ekle.
least_conn, o an en az aktif bağlantısı olan instance'ı seçiyor; istekler kısa olduğu için burada round robin'den farkı görünmüyor, uzun süren raporlar ya da SSE (Server-Sent Events) bağlantıları karışınca fark ediyor. ip_hash aynı client IP'sini hep aynı instance'a gönderiyor; deneydeki 12 istek tek client'tan geldiği için hepsi api2'ye düştü. Basit bir session affinity, ama NAT arkasındaki binlerce kullanıcıyı tek instance'a yığabiliyor.
1.29.6'dan beri açık kaynak Nginx'te cookie tabanlı session affinity de var:
upstream bookstore_api {
zone bookstore_api 64k;
sticky cookie bookstore_srv expires=1h httponly secure;
server 172.30.85.21:8080 max_fails=3 fail_timeout=30s;
server 172.30.85.22:8080 max_fails=3 fail_timeout=30s;
server 172.30.85.23:8080 max_fails=3 fail_timeout=30s;
}
$ curl -s -o /dev/null -D - -c /tmp/j https://bookstore.test/api/whoami | grep -i set-cookie
set-cookie: bookstore_srv=911fa9424219d56541fffcdcbb678d3f; expires=Sun, 08-Nov-26 05:26:21 GMT; max-age=3600; httponly; secure
# cookie ile 6 istek
6 api1
# cookie olmadan 6 istek
2 api1 2 api2 2 api3
İlk yanıt hangi instance'a gidildiğini cookie'ye yazıyor (değer, server adresinin MD5 (Message-Digest 5) hash'i); cookie'yi geri gönderen client hep aynı instance'a gidiyor. Nginx dokümanına göre o instance cevap veremezse client yeni bir instance'a atanıyor. Sticky session'ı gerektiren klasik durum, birden fazla instance'ta WebSocket dışı transport'larla çalışan SignalR; Data Protection key'lerini paylaşmamanın yerine geçmez, üçüncü yazıdaki sorun instance yeniden başlayınca yine çıkar.
max_fails ile Passive Health Check
Şimdi tahminin ikinci yarısı. Zone'lu round robin ile 12 istek, sonra api2'nin process'ini öldürüp 12 istek daha:

all healthy: 4 200 api1 4 200 api2 4 200 api3
api2 down: 6 200 api1 6 200 api3
again (in 30s): 6 200 api1 6 200 api3
[error] 21#21: *26 connect() failed (111: Connection refused) while connecting to upstream ... upstream: "http://172.30.85.22:8080/api/whoami", host: "bookstore.test"
[error] 21#21: *30 connect() failed (111: Connection refused) while connecting to upstream ... upstream: "http://172.30.85.22:8080/api/whoami", host: "bookstore.test"
[error] 21#21: *34 connect() failed (111: Connection refused) while connecting to upstream ... upstream: "http://172.30.85.22:8080/api/whoami", host: "bookstore.test"
[warn] 21#21: *34 upstream server temporarily disabled while connecting to upstream ... upstream: "http://172.30.85.22:8080/api/whoami", host: "bookstore.test"
Client tek bir hata görmedi. api2'ye giden üç istek bağlantı hatası aldı, ama proxy_next_upstream'in varsayılanı (error timeout) sayesinde Nginx her birini bir sonraki instance'a tekrar gönderdi. Üçüncü hatada max_fails=3 doldu ve api2 fail_timeout=30s boyunca devreden çıktı; o süre bitince Nginx ona yeniden bir istek deneyecek.
Bu "passive" health check: Nginx yalnız gerçek isteklerden öğreniyor, kendisi kontrol isteği göndermiyor. İki sonucu var. Birincisi, yeniden deneme yalnız idempotent isteklerde güvenli: Nginx varsayılan olarak POST gibi idempotent olmayan istekleri, upstream'e gönderildikten sonra başka bir instance'a tekrar göndermiyor ve bu ayarı değiştirmek çift sipariş demek olabilir. İkincisi, process ayakta ama sağlıksızsa (veritabanına ulaşamıyor, her isteğe 500 veriyor) Nginx bunu bağlantı hatası saymıyor. Active health check, yani belirli aralıklarla /health'i çağırıp sağlıksız instance'ı çıkarmak, açık kaynak Nginx'te yok; bunun için ASP.NET Core Web API serisinin "Health Check" yazısındaki readiness endpoint'ini bir orkestratöre ya da active check yapan bir load balancer'a bağlaman gerekiyor. Deploy sırasında instance'ı kontrollü çıkarmak için 1.29.6 ile gelen server ... drain parametresine bak.
Üretimde Dikkat
TLS ayarlarını ve güvenlik header'larını ayrı dosyalara koyup include et; her yeni server block'u aynı ayarları almalı. nginx -T çıktısında ssl_protocols ve add_header satırlarını aramak, bir server'ın atlanıp atlanmadığını hızla gösterir.
Rate limit'i ilk gün sıkı koyma. Önce limit_req_dry_run on; ile yalnız log'la, gerçek trafiğin dağılımına bak, sonra eşiği belirle. Login ve şifre sıfırlama gibi endpoint'ler için ayrı, daha sıkı bir zone tanımla.
Upstream instance'larını deploy ederken Nginx'in passive check'ine güvenme. Kestrel'i durdurmadan önce trafiği kes (instance'ı drain ile işaretle ya da upstream'den çıkarıp reload et), açık istekler bitsin, sonra durdur. Graceful shutdown'ın Kestrel tarafı bir sonraki yazıda, systemd ile.
Sık Düşülen Tuzaklar
Location'da tek bir add_header ekleyip güvenlik header'larını kaybetmek. add_header_inherit merge ya da include ile çöz; bir curl testiyle doğrula.
HSTS'i hem Nginx'te hem uygulamada vermek. İki header, iki süre ve hangisinin geçerli olduğu belirsiz.
Birden fazla server'lı upstream'de zone kullanmamak. Round robin dengesiz dağılıyor, health check durumu worker'lar arasında paylaşılmıyor.
CDN ya da load balancer arkasında $binary_remote_addr ile rate limit koymak. Bütün trafik birkaç IP'den geliyor gibi görünür ve ilk kampanyada herkes 429 alır.
HTTP/3'ü açıp Nginx'i güncellememek. QUIC kodu, 2026'daki CVE listesinin önemli bir kısmının kaynağı.
Küçük Bir Deneme
★☆☆ server_tokens off;'u http context'ine taşı ve 80 portundaki 301 yanıtına, ayrıca var olmayan bir sayfanın 404 hata sayfasına bak. Sürüm bilgisi hâlâ bir yerde görünüyor mu?
★★☆ /api/orders için POST kabul eden bir endpoint ekle, api2'yi istek gelince 5 saniye bekleyip process'ini öldürecek şekilde ayarla ve POST gönder. Nginx isteği diğer instance'a tekrar gönderiyor mu? proxy_next_upstream non_idempotent eklediğinde ne değişiyor ve neden tehlikeli?
★★★ Login endpoint'i için dakikada 10 isteklik ayrı bir zone tanımla, Nginx'i bir CDN arkasındaymış gibi kurmak için önüne ikinci bir Nginx koy ve set_real_ip_from ile real_ip_header X-Forwarded-For kullanarak limit'in gerçek client IP'sine göre uygulandığını doğrula.
Nginx artık TLS'i bitiriyor, header'ları koyuyor, trafiği sınırlıyor ve üç Kestrel arasında dağıtıyor. Ama o Kestrel'leri kim başlatıyor, çöktüğünde kim kaldırıyor, deploy'da nasıl durduruluyor? Bir sonraki yazıda .NET uygulamasını Linux'ta systemd service olarak çalıştırıyoruz.