Nginx ile Angular Yayınlamak: SPA Fallback, Cache Header'ları, Compression ve /api Proxy
Angular'ı Nginx ile yayınlarken try_files ile SPA fallback, hash'li bundle'lara immutable ve index.html'e no-cache şart; aksi hâlde deploy sonrası eski chunk'ı isteyen tarayıcı 200 ile HTML alıyor. gzip_static, runtime config.json, aynı origin'de /api proxy ile CORS'suz kurulum, base href ile alt yolda yayın ve Angular SSR'ı Node ile Nginx arkasına koyarken allowedHosts tuzağı gerçek koşularla.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular CLI (Command Line Interface) 22.2.6, Node.js 24.22), Nginx 1.30.6, .NET 10 (ASP.NET Core runtime 10.0.14) ve curl 8.23 ile Docker 29 üzerinde denenmiştir.
Önce Tahmin Et
Bookstore'un Angular uygulaması (bookstore-web) Nginx ile yayında. Bir kullanıcı sabah sayfayı açtı ve sekmeyi açık bıraktı. Öğlen yeni bir sürüm deploy edildi; build, book-list component'inin chunk'ını chunk-JR35WFDJ.js yerine chunk-MU5TWFYZ.js adıyla üretti ve eski dosya sunucudan silindi. Kullanıcı öğleden sonra "Books" linkine tıklıyor; tarayıcının elindeki eski main bundle'ı eski chunk'ı istiyor.
Nginx config'inde yalnız try_files $uri $uri/ /index.html; varsa sunucu bu isteğe hangi status code'u ve hangi Content-Type'ı döner? Kullanıcı ne görür?
Build Çıktısı ve SPA Fallback
ng build'in ürettiği klasörde bir HTML (HyperText Markup Language) dosyası ve içerik hash'li JavaScript dosyaları var:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-E6I2YBCN.js | - | 119.52 kB | 36.02 kB
main-VVATF72S.js | main | 94.45 kB | 24.03 kB
chunk-KLUEAQAT.js | - | 20.67 kB | 6.06 kB
styles-5INURTSO.css | styles | 0 bytes | 0 bytes
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-JR35WFDJ.js | book-list | 1.21 kB | 659 bytes
chunk-5BF2ERJB.js | order-detail | 377 bytes | 377 bytes
Dosya adındaki sekiz karakter içeriğin hash'i: içerik değişirse ad da değişiyor. Uygulama bir SPA (Single Page Application); /books, /orders/42 gibi path'ler diskte yok, onları tarayıcıdaki Angular router çözüyor. Sunucunun işi iki kuraldan ibaret: dosya varsa ver, yoksa index.html'i ver. Nginx'te bu tek satır:
location / {
try_files $uri $uri/ /index.html;
}
try_files argümanları sırayla deniyor: önce istenen dosya, sonra aynı adda klasör, ikisi de yoksa son argümana (/index.html) internal redirect. Angular serisinin "Build ve Deploy" yazısında anlatılan SPA routing gereksiniminin Nginx tarafındaki karşılığı bu. Ama baştaki sorunun cevabı da tam burada:
# release-2 deploy edildi, tarayıcı eski chunk'ı istiyor
# sadece try_files olan config
HTTP/1.1 200 OK
Content-Type: text/html
# hash'li dosyalar için ayrı location ve =404
HTTP/1.1 404 Not Found
Content-Type: text/html
Yalnız try_files olan config'te eski chunk'ın isteği de "yok olan bir path" ve index.html'e düşüyor: JavaScript bekleyen tarayıcıya 200 ile HTML gidiyor. Tarayıcı modülü yükleyemiyor, MIME (Multipurpose Internet Mail Extensions) type hatası veriyor ve kullanıcının tıkladığı link hiçbir şey yapmıyor. Status 200 olduğu için access log'da bir hata da görünmüyor. Hash'li dosyalar için fallback yerine 404 dönen ayrı bir location, sorunu en azından görünür kılıyor ve uygulamanın router'ındaki hata yakalayıcısının sayfayı yenilemesine imkân veriyor. Tam çözüm ise eski chunk'ları bir süre sunucuda tutmak: deploy, eski build'in dosyalarını silmek yerine yenilerini yanına eklesin.
Cache Header'ları: Hash'li Bundle'lar ve index.html
Hash'li bir dosyanın içeriği asla değişmez; değişirse adı değişir. Bu yüzden tarayıcı onu bir yıl boyunca sunucuya hiç sormadan kullanabilir. index.html ise tam tersi: hangi hash'li dosyaların yükleneceğini o söylüyor, eski bir kopyası kullanıcıyı eski sürümde bırakır. Bookstore'un config'i:
upstream bookstore_api {
server 172.30.85.21:8080;
}
server {
listen 80;
server_name bookstore.local;
root /srv/bookstore-web;
gzip on;
gzip_static on;
gzip_vary on;
gzip_types text/css application/javascript application/json;
# hash'li bundle'lar: bir yıl cache, hiç revalidate etme
location ~* "-[A-Z0-9]{8}\.(js|css)$" {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# giriş noktası ve runtime config: her seferinde revalidate et
location = /index.html {
add_header Cache-Control "no-cache";
}
location = /config.json {
add_header Cache-Control "no-cache";
}
# aynı origin'de API: CORS gerekmez
location /api/ {
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;
}
# SPA fallback: bilinmeyen path'lere index.html, gerisini Angular router halleder
location / {
try_files $uri $uri/ /index.html;
}
}
Dört isteğin yanıt header'ları:
== /orders/42
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 329
Vary: Accept-Encoding
ETag: "6aeff7d0-149"
Content-Encoding: gzip
Cache-Control: no-cache
== /index.html
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 329
Vary: Accept-Encoding
ETag: "6aeff7d0-149"
Content-Encoding: gzip
Cache-Control: no-cache
== /config.json
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 65
Vary: Accept-Encoding
ETag: "6aeff7d0-41"
Content-Encoding: gzip
Cache-Control: no-cache
== /main-VVATF72S.js
HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: 26710
Vary: Accept-Encoding
ETag: "6aeff7d0-6856"
Content-Encoding: gzip
Cache-Control: public, max-age=31536000, immutable
/orders/42'nin de no-cache alması önemli: try_files'ın internal redirect'i isteği location = /index.html'e taşıdı ve header orada eklendi. no-cache "cache'leme" değil, "kullanmadan önce sunucuya sor" demek; tarayıcı ETag ile soruyor, dosya değişmediyse 304 alıyor ve yalnız birkaç yüz byte'lık bir istek harcanıyor. immutable ise tarayıcıya "sayfa yenilense bile sorma" diyor.
Regex location'ın varlığı önceki yazılardaki bir uyarıyı da hatırlatıyor: add_header bir location'da tanımlanınca üst context'teki add_header'lar o location'da geçerli olmaz. Sunucu genelinde bir güvenlik header'ı eklediğinde bu location'larda kaybolur; yedinci yazıda add_header_inherit ile buna döneceğiz.
Compression: gzip, gzip_static ve Brotli
gzip on her istekte dosyayı sıkıştırıyor ve CPU (Central Processing Unit) harcıyor. Build çıktısı değişmediğine göre sıkıştırmayı bir kez, deploy sırasında yapmak daha mantıklı. gzip_static on, istenen dosyanın yanında .gz uzantılı bir kopyası varsa onu doğrudan gönderiyor:
$ for f in *.js *.css *.json index.html; do gzip -k -9 "$f"; done
$ ls -l | awk '{print $5, $9}'
377 chunk-5BF2ERJB.js
292 chunk-5BF2ERJB.js.gz
119518 chunk-E6I2YBCN.js
40092 chunk-E6I2YBCN.js.gz
1213 chunk-JR35WFDJ.js
721 chunk-JR35WFDJ.js.gz
20666 chunk-KLUEAQAT.js
6732 chunk-KLUEAQAT.js.gz
54 config.json
65 config.json.gz
15086 favicon.ico
528 index.html
329 index.html.gz
94452 main-VVATF72S.js
26710 main-VVATF72S.js.gz
0 styles-5INURTSO.css
20 styles-5INURTSO.css.gz
Yukarıdaki yanıtta main-VVATF72S.js için Content-Length: 26710 geldi; bu tam olarak .gz dosyasının boyutu, yani Nginx sıkıştırmadı, hazır dosyayı gönderdi. Dinamik gzip'te boyut önceden bilinmediği için yanıt Transfer-Encoding: chunked ile gider. Listede bir ders daha var: 54 byte'lık config.json sıkıştırılınca 65 byte oldu ve gzip_static bu büyük dosyayı gönderdi. Birkaç yüz byte'ın altındaki dosyaları önceden sıkıştırma; dinamik gzip'te bu sınırı gzip_min_length belirliyor.
gzip_vary on, yanıta Vary: Accept-Encoding ekliyor; aradaki bir CDN (Content Delivery Network) ya da proxy cache'i sıkıştırılmış kopyayı gzip desteklemeyen bir client'a vermesin diye. Brotli, gzip'ten daha iyi sıkıştırıyor, ama Nginx'in kendisinde yok: resmi image'ın modül klasöründe ngx_http_acme_module, njs, geoip, image_filter ve xslt var, brotli yok. Brotli için üçüncü taraf ngx_brotli modülünü derlemen ya da onu paketleyen bir dağıtım kullanman gerekiyor; çoğu kurulumda brotli'yi önündeki CDN'e bırakmak daha basit.
Runtime config.json
Angular'ın environment.ts dosyaları build sırasında koda gömülüyor; staging ve production için ayrı build demek. Aynı build'i her ortama taşımak istiyorsan ortam bilgisini bir JSON (JavaScript Object Notation) dosyasından, uygulama açılırken okursun:
import { Injectable, signal } from '@angular/core';
export interface AppConfig {
apiBaseUrl: string;
environment: string;
}
@Injectable({ providedIn: 'root' })
export class AppConfigService {
readonly config = signal<AppConfig>({ apiBaseUrl: 'api', environment: 'unknown' });
async load(): Promise<void> {
const response = await fetch('config.json', { cache: 'no-store' });
this.config.set(await response.json());
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(),
provideRouter(routes, withComponentInputBinding()),
provideAppInitializer(() => inject(AppConfigService).load()),
]
};
provideAppInitializer uygulama açılmadan önce promise'in bitmesini bekliyor. Dosya build'de public/config.json olarak duruyor; deploy, ortamın dosyasını onun üstüne yazıyor ya da Nginx'te o ortama ait bir dosyayı mount ediyor. Bu dosyanın da no-cache alması şart, yoksa ortam değişikliği tarayıcıya ulaşmaz. İçine secret koyma: tarayıcıya giden her şey herkese açık.
Aynı Origin'de /api: CORS'suz Kurulum
Angular https://bookstore.example'dan, API (Application Programming Interface) https://api.bookstore.example'dan yayınlanıyorsa tarayıcı için bunlar iki ayrı origin ve her istek CORS (Cross-Origin Resource Sharing) kurallarına tabi: API'de policy tanımlamak, credential'lı isteklerde ek ayarlar ve her POST'tan önce bir preflight OPTIONS isteği. Yukarıdaki config'teki location /api/ ikisini aynı origin'e topluyor; tarayıcı /api/books'u kendi sayfasının bir parçası sanıyor:
$ curl -s -H "Host: bookstore.local" http://172.30.85.10/api/books | head -c 120
[{"id":1,"title":"Dune","author":"Frank Herbert","price":12.50},{"id":2,"title":"Foundation","author":"Isaac Asimov","pr
Preflight yok, CORS policy'si yok, cookie'ler doğal olarak gidiyor. Angular kodunda URL'ler (Uniform Resource Locator) 'api/books' gibi göreli kalıyor; ortam farkını config değil Nginx çözüyor. Ayrı origin'in gerektiği durumlar (API'yi başka uygulamalar da kullanıyorsa) için CORS'un kendisi ASP.NET Core Web API serisinin "CORS ve Güvenlik" yazısında.
Sub-Path'te Yayın: base href
Bazen uygulama domain'in kökünde değil, /shop/ altında yayınlanır. Angular'a bunu build sırasında söylüyorsun:
$ npx ng build --base-href /shop/ --output-path dist-shop
$ grep -o '<base[^>]*>\|src="[^"]*"' dist-shop/browser/index.html
<base href="/shop/">
src="main-TAGGIZ6J.js"
Dosya yolları göreli kalıyor; tarayıcı onları <base href>'e göre çözüyor. Nginx tarafında fallback'in de alt yolu göstermesi gerekiyor:
server {
listen 80;
server_name bookstore.local;
root /srv/sites;
location /shop/ {
try_files $uri $uri/ /shop/index.html;
}
}
$ curl -s -H "Host: bookstore.local" http://172.30.85.10/shop/orders/42 | grep -o "<base[^>]*>"
<base href="/shop/">
$ curl -s -o /dev/null -w "/shop/main-TAGGIZ6J.js -> %{http_code} %{content_type}\n" -H "Host: bookstore.local" http://172.30.85.10/shop/main-TAGGIZ6J.js
/shop/main-TAGGIZ6J.js -> 200 application/javascript
Build çıktısını /srv/sites/shop'a koyup root kullandım; URL ile disk yapısı aynı olunca dördüncü yazıdaki alias tuzaklarına hiç girmiyorsun. Bir ayrıntı: göreli 'api/books' ve 'config.json' da artık /shop/api/books ve /shop/config.json'a gidiyor. API proxy'sini ya /shop/api/ olarak tanımla ya da Angular'da API adresini kökten (/api/books) ver.
Angular SSR: Node Server'ı Nginx Arkasına Koymak
SSR (Server-Side Rendering) ile sayfa ilk istekte sunucuda render ediliyor; arama motorları ve yavaş cihazlar dolu HTML alıyor. Bu durumda Nginx artık yalnız dosya sunmuyor, bir Node.js process'ine proxy yapıyor. ng new --ssr ile üretilen bir uygulamayı, route'u RenderMode.Server yapıp build ettim ve node server/server.mjs ile 4000 portunda çalıştırdım. Nginx config'i API proxy'sinin aynısı:
upstream bookstore_ssr {
server 172.30.85.40:4000;
}
server {
listen 80;
server_name bookstore.local;
location / {
proxy_pass http://bookstore_ssr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
İlk istek 400 döndü. Node'un log'u:
Received "x-forwarded-for" header but "trustProxyHeaders" was not set up to allow it.
For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers
Received "x-forwarded-proto" header but "trustProxyHeaders" was not set up to allow it.
For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers
ERROR: Bad Request ("http://bookstore.local/").
Header "host" with value "bookstore.local" is not allowed.
Angular'ın SSR engine'i, SSRF (Server-Side Request Forgery) saldırılarına karşı yalnız izin verilen host'lara render yapıyor ve X-Forwarded-* header'larını varsayılan olarak yok sayıyor. ASP.NET Core'un forwarded headers middleware'iyle aynı mantık, üçüncü yazıda gördüğümüz güven kuralları burada da geçerli. İzin listesi angular.json'da security.allowedHosts ile ya da ortam değişkeniyle veriliyor:
$ docker run -d --name webhwork-ssr ... -e PORT=4000 \
-e NG_ALLOWED_HOSTS=bookstore.local \
-e NG_TRUST_PROXY_HEADERS=x-forwarded-proto,x-forwarded-for \
node:24-alpine node server/server.mjs
$ curl -s -H "Host: bookstore.local" http://172.30.85.10/ | grep -o "<h1[^>]*>[^<]*</h1>\|<app-root[^>]*>" | head -3
<app-root ng-version="22.2.5" _nghost-ng-c1687365377="" ngh="0" ng-server-context="ssr">
<h1 _ngcontent-ng-c1687365377="">Hello, bookstore-ssr</h1>
ng-server-context="ssr" sayfanın sunucuda render edildiğini gösteriyor. Production'da bu Node process'ini Kestrel gibi systemd ayakta tutar; unit dosyası sekizinci yazıdaki Kestrel unit'iyle aynı yapıda (ExecStart=/usr/bin/node /var/www/bookstore-ssr/server/server.mjs, ayrı bir kullanıcı, Restart=always). Bu denemede Node'u container'da çalıştırdım, systemd altında değil. Statik dosyaları Node'a sordurmak yerine browser klasörünü Nginx'ten vermek de mümkün; hash'li dosyalar için yukarıdaki location'ı SSR config'ine eklemen yeter.
| Soru | Statik (CSR) | SSR |
|---|---|---|
| Sunucuda çalışan | Yalnız Nginx | Nginx ve Node.js process'i |
| İlk anlamlı içerik | JavaScript indirilip çalışınca | İlk HTML yanıtında |
| SEO (Search Engine Optimization) ve link önizleme | Zayıf | Güçlü |
| Ölçek ve maliyet | Dosya sunmak, CDN'e taşınabilir | Her istek CPU harcar, process izlenmeli |
| Kim için | Giriş arkasındaki yönetim ekranları, iç uygulamalar | Herkese açık katalog, kitap detay sayfaları |
Bookstore için makul bölüşüm: herkese açık katalog SSR, sipariş ve yönetim ekranları statik. Angular'da bu ayrımı route bazında RenderMode ile yapabiliyorsun; Nginx tarafında tek bir upstream yetiyor.
Üretimde Dikkat
Deploy sırasını cache stratejisine göre kur: önce yeni hash'li dosyalar, en son index.html. Tersi olursa, yeni index.html'i alan bir kullanıcı henüz kopyalanmamış dosyaları isteyip 404 alır. Eski build'in dosyalarını en az bir sürüm boyunca sunucuda bırak; açık sekmeler onları isteyecek.
Nginx tarafında cache header'larını test eden bir deploy sonrası kontrol yaz: index.html'de no-cache, hash'li bir dosyada immutable, rastgele bir derin linkte 200 ve text/html, var olmayan bir .js'te 404. Dört curl, yanlış bir regex'i production'a çıkmadan yakalar.
Önde bir CDN varsa aynı header'lar onu da yönlendirir. index.html CDN'de cache'lenirse deploy kullanıcılara ancak CDN'in süresi dolunca ulaşır; bu yüzden ya no-cache'e güven ya da deploy'da CDN'e purge isteği gönder.
Sık Düşülen Tuzaklar
index.html'i uzun süre cache'lemek. Deploy sonrası kullanıcılar eski sürümde kalır, eski chunk'lar silindiyse uygulama yarı çalışır. Belirtisi, destek ekibine "bende düzeldi, müşteride hâlâ bozuk" diye gelen şikâyetler.
Hash'li dosyalara da SPA fallback uygulamak. Eksik bir chunk 404 yerine 200 ile HTML döner ve hata hiçbir log'da görünmez.
Küçük dosyaları önceden sıkıştırmak. config.json örneğindeki gibi .gz asıl dosyadan büyük olabilir ve gzip_static onu sorgusuz gönderir.
config.json'a secret koymak ya da onu cache'lemek. Tarayıcıya giden dosya gizli değildir; cache'lenen dosya da ortam değişikliğini yutar.
SSR'ı Nginx arkasına koyup allowedHosts'u unutmak. Sonuç 400 ve Node log'unda SSRF uyarısı; localhost'ta her şey çalıştığı için sorun ilk deploy'da çıkar.
Küçük Bir Deneme
★☆☆ Bookstore config'inde regex location'daki try_files $uri =404; satırını sil ve eski chunk isteğini tekrarla. Status code ne oldu ve neden? Nginx'in regex location'da root'tan dosyayı nasıl bulduğunu açıkla.
★★☆ Deploy script'i yaz: yeni build'i releases/<tarih> klasörüne kopyalasın, önceki sürümün hash'li dosyalarını yeni klasöre de kopyalasın, .gz dosyalarını üretsin ve en son current symlink'ini çevirsin. Sabah açılmış bir sekmenin deploy sonrası "Books" linkine tıklayabildiğini doğrula.
★★★ SSR uygulamasında hash'li dosyaları Nginx'ten, sayfaları Node'dan ver: browser klasörünü Nginx'e mount et, hash'li dosyalar için regex location ekle, gerisini bookstore_ssr'a gönder. Node'un access log'unda artık hangi istekler görünüyor?
Bookstore'un iki yarısı da Nginx arkasında, ama hâlâ düz HTTP (Hypertext Transfer Protocol) üzerinden. Bir sonraki yazıda Nginx'te TLS (Transport Layer Security) termination'ı, HTTP/2 ve HTTP/3'ü, HSTS (HTTP Strict Transport Security) ve diğer security header'larını, rate limiting'i ve birden fazla Kestrel instance'ı arasında load balancing'i kuruyoruz.