Angular Uygulamasını Container'a Almak: Node Build Stage, Unprivileged Nginx ve Runtime Configuration
Angular uygulaması Node'lu bir build stage'inde derlenir ve yalnız statik dosyalar root olmayan bir nginx image'ına kopyalanır; ortama göre değişen API adresi build'e değil, container başlarken environment variable'lardan yazılan config.json'a konur. bookstore-web için Dockerfile'ı, SPA fallback ve cache header'larını, provideAppInitializer ile runtime config'i, CORS'u, SSR'ın ne zaman gerektiğini ve node_modules tuzağını gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki örnekler Docker Engine 29.9.3, Angular 22.2 (Angular CLI 22.2.7, TypeScript 6.0), node:26-alpine (Node.js 26, LTS (Long Term Support; uzun süreli destek) sürümü), nginxinc/nginx-unprivileged:1.30-alpine (nginx 1.30.6) ve .NET 10 ile denenmiştir.
Önce Tahmin Et
bookstore-web'in API adresi environment.prod.ts'de yazılı ve image CI'da bir kez build ediliyor. Aynı image'ı staging'de ve production'da çalıştırmak istiyorsun; iki ortamın API adresi farklı. Kaç image build etmen gerekir? İkinci soru: docker build'i, makinende npm install yaptığın klasörde, .dockerignore olmadan çalıştırırsan build context'i kaç MB olur: 1, 20 yoksa 200?
bookstore-web ve İki Stage'li Dockerfile
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'i repository'deki web/ klasörüne Angular CLI 22.2.7 ile oluşturduk (ng new bookstore-web --directory web; standalone ve signals varsayılan, Angular 22 projeleri TypeScript 6.0 ile geliyor). Şimdilik tek bir sayfası var: Bookstore.Api'den kitap listesini çekip gösteriyor. Bir SPA (Single Page Application; tek sayfalık web uygulaması) olan Angular uygulaması build edildikten sonra statik dosyalardan ibaret (HTML, JavaScript, CSS); production'da onu Node değil, bir web server sunar. Dockerfile bu yüzden iki stage'li:
# syntax=docker/dockerfile:1
# build: Node build makinesinin platformunda çalışır; çıktı düz JavaScript
FROM --platform=$BUILDPLATFORM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=npm,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# final: root olmayan nginx (uid 101, port 8080) statik dosyaları sunar
FROM nginxinc/nginx-unprivileged:1.30-alpine AS final
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/config.template.json /etc/bookstore/config.template.json
COPY --chmod=755 nginx/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
COPY --from=build /app/dist/bookstore-web/browser /usr/share/nginx/html
# config.json başlangıçta yeniden yazılır, bu yüzden sahibi nginx kullanıcısı olmalı
COPY --from=build --chown=101:101 /app/dist/bookstore-web/browser/config.json /usr/share/nginx/html/config.json
EXPOSE 8080
6. ve 7. yazılardaki kurallar aynen geçerli. Önce package.json ve package-lock.json, ardından npm ci, en son kaynak kod: bir component'i değiştirdiğinde paketler yeniden kurulmuyor. npm ci, npm install'dan farklı olarak yalnız lockfile'daki sürümleri kurar ve lockfile ile package.json uyuşmazsa hata verir; build'in her seferinde aynı paketleri kullanması için doğru komut bu. FROM --platform=$BUILDPLATFORM ile Node stage'i her zaman build makinesinin mimarisinde çalışıyor; çıktı mimariden bağımsız JavaScript olduğu için 7. yazıdaki -a $TARGETARCH gibi bir ayara bile gerek yok, çok mimarili bir web image'ı için yalnız final stage'in nginx'i değişiyor.
Angular 17'den beri uygulama builder'ı tarayıcı dosyalarını dist/<proje>/browser altına yazar; eski Dockerfile örneklerindeki dist/<proje> yolu bugün index.html'i değil, browser klasörünü içeren bir üst klasörü gösteriyor. Build log'u çıktının yerini açıkça yazıyor:
docker build -t dockerseries-bookstore-web:1.0.0 web
#14 1.257 Initial chunk files | Names | Raw size | Estimated transfer size
#14 1.257 main-BCJ2DRL6.js | main | 143.48 kB | 42.78 kB
#14 1.257 styles-5INURTSO.css | styles | 0 bytes | 0 bytes
#14 1.257 | Initial total | 143.48 kB | 42.78 kB
#14 1.257 Application bundle generation complete. [0.867 seconds] - 2026-11-14T11:47:40.547Z
#14 1.257 Output location: /app/dist/bookstore-web
Build context web/ klasörü. 6. yazıda NuGet için yaptığımız cache mount ölçümünü npm için de yaptık: --no-cache ile iki kez, cache mount'lu ve cache mount'suz. 241 paketin npm ci'ı cache mount'suz 3,4 ve 5,0 saniye, cache mount'lu 3,7 ve 4,9 saniye sürdü. Bu bağlantıda fark yok; npm registry'si hızlı yanıt verdiğinde indirme süresi belirleyici olmuyor. Cache mount'u yine de bırakıyoruz: yavaş ya da kotalı bir bağlantıda, şirket içi bir npm proxy'sinin arkasında ve paket sayısı arttıkça kazanç ortaya çıkıyor, maliyeti ise yok.
6. yazıdan beri büyüyen docker-bake.hcl'e bir web target'ı ekledik, onu default ve ci gruplarına ve lint matrix'ine koyduk; docker buildx bake ci artık üç uygulamanın check'ini, Tools testlerini ve üç image'ı tek oturumda çalıştırıyor:
target "web" {
context = "web"
target = "final"
tags = ["dockerseries-bookstore-web:${TAG}"]
}
Root Olmayan nginx ve Image Boyutu
Resmi nginx image'ı master process'i root olarak başlatır ve 80'i dinler. nginxinc/nginx-unprivileged, nginx'in kendi ekibinin yayınladığı varyant: bütün process'ler 101 uid'li nginx kullanıcısıyla çalışır ve varsayılan port 8080. 8. yazıda ASP.NET Core image'larında gördüğümüz tercihin aynısı. Container'ın içinden bakınca:
docker exec dockerseries-web id
docker exec dockerseries-web ls -ln /usr/share/nginx/html
uid=101(nginx) gid=101(nginx) groups=101(nginx)
total 172
-rw-r--r-- 1 0 0 497 Oct 2 17:14 50x.html
-rw-r--r-- 1 101 101 72 Nov 14 11:52 config.json
-rw-r--r-- 1 0 0 15086 Nov 14 11:46 favicon.ico
-rw-r--r-- 1 0 0 426 Nov 14 11:47 index.html
-rw-r--r-- 1 0 0 143482 Nov 14 11:47 main-BCJ2DRL6.js
Uygulamanın dosyaları root'a ait ve nginx kullanıcısı onları yalnız okuyabiliyor; ele geçirilen bir nginx process'i JavaScript dosyalarını değiştiremez. Tek istisna config.json: onu container başlarken yeniden yazacağımız için --chown=101:101 ile kopyaladık. Image'ın kendisi küçük: linux/amd64 için base image 90.1 MB / 25.6 MB, bookstore-web eklenince 90.4 MB / 25.6 MB. Node, node_modules ve Angular CLI build stage'inde kaldı.
nginx.conf: SPA Fallback ve Cache Header'ları
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
# SPA fallback: bilinmeyen path'ler index.html'e gider, Angular router devralır
location / {
try_files $uri $uri/ /index.html;
}
# Dosya adları içerik hash'i taşır, bir yıl cache'lenebilir
location ~* \.(?:js|css|woff2?|ico|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Asla cache'lenmemeli: dosya adı değişmeden içerikleri değişir
location = /index.html {
add_header Cache-Control "no-cache";
}
location = /config.json {
add_header Cache-Control "no-store";
}
}
Bu ayarların neden böyle olduğunu Web Server'lar ve Hosting serisinin Nginx ile Angular yayınlama yazısı ve Angular serisinin "Angular Build ve Deploy: ng build, Environment Dosyaları, Budgets ve SPA Routing" yazısı anlatıyor; burada container'daki sonucu kontrol ediyoruz. try_files, diskte karşılığı olmayan bir path'e (kullanıcının yer imine eklediği bir kitap sayfası gibi) 404 yerine index.html döner. Hash'li dosya adları bir yıl, index.html her istekte doğrulanarak, config.json hiç cache'lenmeden sunulur:
curl -s -o /dev/null -D - localhost:8081/books/9780441172719 | grep -iE "^HTTP|cache-control"
curl -s -o /dev/null -D - localhost:8081/main-BCJ2DRL6.js | grep -iE "^HTTP|cache-control"
curl -s -o /dev/null -D - localhost:8081/config.json | grep -iE "^HTTP|cache-control"
HTTP/1.1 200 OK
Cache-Control: no-cache
HTTP/1.1 200 OK
Cache-Control: max-age=31536000
Cache-Control: public, immutable
HTTP/1.1 200 OK
Cache-Control: no-store
Kendi default.conf'umuzu koyduğumuz için image'ın başlangıç script'lerinden 10-listen-on-ipv6-by-default.sh dosyayı değiştiremediğini log'luyor (can not modify /etc/nginx/conf.d/default.conf); dosya root'a ait ve bilerek salt okunur, bu satır beklenen bir durum.
Build Once, Run Anywhere: Runtime config.json
İlk sorunun cevabı environment dosyalarıyla "her ortam için bir image". environment.prod.ts'deki API adresi build sırasında JavaScript'e gömülür; staging için ayrı bir build gerekir. Bu, CI/CD Temelleri serisinin "Build Once, Deploy Many" ilkesine aykırı: production'a giden image, staging'de test edilen image olmaz. Çözüm, ortama göre değişen değerleri build'den çıkarıp çalışma anına taşımak. Angular tarafında uygulama açılmadan önce config.json'u okuyan küçük bir servis:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export interface RuntimeConfig {
apiBaseUrl: string;
environment: string;
}
@Injectable({ providedIn: 'root' })
export class RuntimeConfigService {
private readonly http = inject(HttpClient);
private readonly config = signal<RuntimeConfig | null>(null);
get value(): RuntimeConfig {
const config = this.config();
if (!config) {
throw new Error('Runtime config is not loaded');
}
return config;
}
// config.json'u Angular build'i değil, container başlarken script yazar
async load(): Promise<void> {
this.config.set(await firstValueFrom(this.http.get<RuntimeConfig>('config.json')));
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch()),
// Uygulama config.json yüklendikten sonra başlar
provideAppInitializer(() => inject(RuntimeConfigService).load()),
],
};
provideAppInitializer, eski APP_INITIALIZER token'ının fonksiyon tabanlı karşılığı; döndürdüğü Promise tamamlanana kadar Angular uygulamayı başlatmaz, böylece katalog servisi ilk isteğini attığında adres hazır. Geliştirme sırasında public/config.json'daki varsayılan (http://localhost:5080, Bookstore.Api'nin dotnet run adresi) kullanılıyor. Container'da ise bu dosyayı nginx image'ının başlangıç mekanizmasıyla yeniden yazıyoruz: image'ın entrypoint'i, nginx'i başlatmadan önce /docker-entrypoint.d/ altındaki çalıştırılabilir script'leri sırayla çalıştırır.
#!/bin/sh
# Container başlarken config.json'u environment variable'lardan yazar
set -eu
: "${API_BASE_URL:?API_BASE_URL must be set}"
export APP_ENVIRONMENT="${APP_ENVIRONMENT:-production}"
envsubst '${API_BASE_URL} ${APP_ENVIRONMENT}' \
< /etc/bookstore/config.template.json \
> /usr/share/nginx/html/config.json
echo "$0: config.json written for $APP_ENVIRONMENT, API at $API_BASE_URL"
{
"apiBaseUrl": "${API_BASE_URL}",
"environment": "${APP_ENVIRONMENT}"
}
envsubst'e değiştireceği değişkenlerin listesini açıkça verdik; liste verilmezse şablondaki bütün $ ifadelerini (bulamadıklarını boş metne çevirerek) değiştirir. API_BASE_URL zorunlu; ${VAR:?mesaj} değişken yoksa script'i hata koduyla durdurur. Aynı image'ı staging ayarlarıyla çalıştırdık. Bookstore.Api'yi de yanına açtık; tarayıcıda farklı bir origin'den (8081) API'ye (8080) istek gidecek, bu yüzden Api'ye configuration'dan okunan bir CORS (Cross-Origin Resource Sharing; tarayıcının başka origin'e istek izni) politikası ekledik:
string[] allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigins).WithMethods("GET").AllowAnyHeader()));
docker run -d --name dockerseries-api -p 8080:8080 \
-e Cors__AllowedOrigins__0=http://localhost:8081 dockerseries-bookstore-api:1.0.0
docker run -d --name dockerseries-web -p 8081:8080 \
-e API_BASE_URL=http://localhost:8080 -e APP_ENVIRONMENT=staging dockerseries-bookstore-web:1.0.0
docker logs dockerseries-web 2>&1 | grep -E "40-runtime|Configuration complete"
curl -s localhost:8081/config.json
curl -s -o /dev/null -D - -H "Origin: http://localhost:8081" localhost:8080/books | grep -i access-control
/docker-entrypoint.sh: Launching /docker-entrypoint.d/40-runtime-config.sh
/docker-entrypoint.d/40-runtime-config.sh: config.json written for staging, API at http://localhost:8080
/docker-entrypoint.sh: Configuration complete; ready for start up
{
"apiBaseUrl": "http://localhost:8080",
"environment": "staging"
}
Access-Control-Allow-Origin: http://localhost:8081
Sayfayı headless Chromium ile bir kitap sayfasının derin linkinden açtık. Tarayıcı önce index.html'i (SPA fallback ile), sonra config.json'u, en son API'nin /books endpoint'ini istedi ve sayfa şunu gösterdi:
Bookstore
Environment: staging
Çalıkuşu
TRY240.00
Dune
TRY420.00
Emma
TRY180.00
İnce Memed
TRY310.00
(Liste kısaltıldı.) Production'da aynı image -e API_BASE_URL=https://api.bookstore.example.com -e APP_ENVIRONMENT=production ile başlar; ilk sorunun cevabı bir image. Değişkeni unutursan container başlamaz:
docker run --rm dockerseries-bookstore-web:1.0.0
echo "exit code: $?"
/docker-entrypoint.d/40-runtime-config.sh: line 5: API_BASE_URL: API_BASE_URL must be set
exit code: 2
Yanlış yapılandırılmış bir container'ın boş ya da yanlış adresli bir sayfa sunarak "çalışıyor" görünmesindense hemen ölmesi iyi; orkestratör bunu başarısız deploy olarak görür. config.json'a koyduğun her şeyin tarayıcıya gittiğini unutma: API adresi, ortam adı, açık feature flag'ler burada durur; secret burada durmaz. Aynı fikrin .NET tarafıyla birlikte ayrıntılı tartışması Configuration ve Secret Management serisinin "Tek Artifact, Çok Environment: .NET'te Configuration Layering, Environment Variable'lar ve Angular'da Runtime Config" yazısında.
SSR Varyantı Ne Zaman Gerekir?
Buraya kadar bookstore-web tamamen tarayıcıda çalışan bir SPA: nginx yalnız dosya sunuyor. Angular SSR (Server-Side Rendering; sayfanın HTML'ini sunucuda üretme) açıldığında build çıktısında browser klasörünün yanına bir de server klasörü gelir ve sayfaları üreten bir Node.js server'ı çalışır. Container tarafında değişen, final stage'in nginx yerine bir Node runtime image'ı (ör. node:26-alpine, USER node ile) olması ve container'ın bir Node process'i çalıştırması; build stage'i aynı kalır. Ne zaman gerektiği container'dan bağımsız bir karar: arama motorlarının ve sosyal medya önizlemelerinin JavaScript çalıştırmadan dolu HTML görmesi gerekiyorsa (Bookstore'un katalog ve kitap sayfaları gibi) ya da ilk boyamanın yavaş cihazlarda hızlanması önemliyse SSR değerlidir. Sipariş paneli, yönetim ekranı gibi giriş arkasındaki sayfalar için statik SPA ve nginx daha basit ve daha ucuz: Node process'i yok, bellek ve CPU sabit, ölçeklemek dosya sunmak kadar kolay. SSR'ın runtime config'i de farklı: Node server'ı environment variable'ı doğrudan okuyabilir.
Üretimde Dikkat
Lockfile'ı commit'le ve build'de yalnız npm ci kullan. Lockfile'sız bir npm install, aynı commit'ten iki farklı günde iki farklı bağımlılık ağacı üretebilir.
index.html'i ve config.json'u asla uzun süreli cache'leme. Yeni bir sürüm deploy edildiğinde tarayıcı eski index.html'i cache'ten okursa artık var olmayan hash'li dosyaları ister ve sayfa boş kalır.
TLS'i (HTTPS'in şifreleme katmanı) ve sıkıştırmayı bu container'da değil önündeki reverse proxy'de ya da CDN'de (Content Delivery Network; içeriği kullanıcıya yakın sunuculardan dağıtan ağ) yap; 8. yazıdaki API'yle aynı mantık. İleride web ve API'yi aynı origin altında (/api path'i ile) sunarsan CORS'a da gerek kalmaz; bu, container network'ünü gerektirdiği için 12. yazının konusu.
Base image'ları sabitle ve güncel tut: node:26-alpine yalnız build'de kullanılıyor ama derleme araçlarının güvenliği de tedarik zincirinin parçası; nginx-unprivileged ise doğrudan internete açık. 4. yazıdaki digest pin ve otomatik güncelleme ikisi için de geçerli.
Sık Düşülen Tuzaklar
node_modules'u build context'e göndermek. İkinci sorunun cevabı: web/.dockerignore'u geçici olarak kaldırıp build ettiğimizde context'in boyutu şuydu:
#10 transferring context: 224.68MB 2.4s done
Doğru .dockerignore ile aynı adım 1.04kB. Boyuttan kötüsü doğruluk: COPY . ., container'daki npm ci'ın kurduğu node_modules'un üzerine host'unkini yazar. Windows'ta kurulmuş bir node_modules, esbuild gibi native binary taşıyan paketlerin Windows sürümlerini içerir ve Linux container'ında build kırılır. web/.dockerignore:
node_modules/
dist/
.angular/
.vscode/
*.md
Dockerfile
npm install ile lockfile dışı sürüm kurmak. Dockerfile'da npm install yazmak, package.json'daki ^22.2.0 gibi aralıklar içinde o gün en yeni olan sürümü kurabilir; build'ler tekrar üretilebilir olmaktan çıkar. npm ci lockfile'la uyuşmayan bir durumda hata verir, bu da istediğimiz davranış.
Build sırasında API adresini image'a gömmek. --build-arg API_URL=... ile environment.ts'yi değiştirmek de aynı sonuca götürür: ortam başına bir image. Ortama göre değişen değer çalışma anında gelmeli.
Yanlış dist yolunu kopyalamak. COPY --from=build /app/dist/bookstore-web /usr/share/nginx/html yazınca nginx'in kökünde index.html değil browser/ klasörü oluyor. Bizim image'da bu hata başlangıçta görünür, çünkü script config.json'u beklediği yerde bulamıyor ve root'a ait klasörde yeni dosya da yaratamıyor:
/docker-entrypoint.d/40-runtime-config.sh: line 8: can't create /usr/share/nginx/html/config.json: Permission denied
Angular 17 ve sonrasında doğru yol dist/<proje>/browser.
Küçük Bir Deneme
★☆☆ Aynı image'dan ikinci bir container'ı -p 8082:8080 -e APP_ENVIRONMENT=production -e API_BASE_URL=http://localhost:8080 ile başlat ve iki container'ın config.json'larını karşılaştır. Image ID'leri aynı mı?
★★☆ nginx/default.conf'ta try_files satırını try_files $uri =404; yap, image'ı yeniden build et ve /books/9780441172719 adresini curl ile iste. Ne döndü? Tarayıcıda ana sayfadan uygulama içi bir link'e tıklasaydın fark eder miydi?
★★★ Container'ı --read-only ile başlatmayı dene. Hangi dosyalar yazılamadığı için başlangıç başarısız oluyor? Yalnız gereken klasörleri --tmpfs ile yazılabilir yaparak container'ı ayağa kaldır (13. yazıya hazırlık).
Bookstore'un üç parçası da (Bookstore.Tools, Bookstore.Api ve bookstore-web) artık image olarak hazır ve docker buildx bake üçünü tek komutla derliyor. Eksik olan kalıcı veri: Bookstore.Api'nin katalogu hâlâ bellekte. Bir sonraki yazıda SQL Server 2025 container'ını ve verinin container silindiğinde de kalması için volume'leri ele alıyoruz.