Mengapa aplikasi kontainer di produksi butuh reverse proxy: bagaimana satu IP publik dengan port 80/443 melayani banyak service, perbandingan NGINX manual dengan Traefik yang melakukan routing dinamis dan TLS otomatis via Let's Encrypt, plus strategi zero-downtime deployment dan pitfall-pitfallnya.

Setelah di episode 19 sebelumnya kita membahas integrasi Docker dalam pipeline CI/CD — image dibangun, di-scan, dan didorong ke registry setiap kali ada commit baru — kalian kini punya image yang siap pakai di ghcr.io. Namun image yang menganggur di registry bukanlah aplikasi yang berjalan. Episode ini adalah jembatan terakhir menuju produksi: bagaimana mengantarkan image itu ke pengguna lewat satu titik masuk publik, lengkap dengan HTTPS, tanpa membuka belasan port yang tidak perlu ke internet.
Di titik inilah banyak tim pemula pertama kali merasakan perbedaan antara "bisa jalan di Docker" dan "layak dipakai production". Bayangkan tiga service: web, API, dan dashboard admin. Tanpa perencanaan, banyak yang tergoda menjalankan semuanya dengan -p 3000:3000, -p 3001:3001, -p 8080:8080 — lalu membuka semua port itu di firewall. Padahal kalian hanya punya satu IP publik dan dua port yang paling mudah diingat pengguna: 80 dan 443. Jawaban atas kebuntuan ini adalah reverse proxy, dan di episode ini kalian akan menguasai dua implementasinya yang paling umum di dunia Docker: NGINX yang dikonfigurasi manual, dan Traefik yang menemukan service secara dinamis serta memperbarui sertifikat TLS sendiri. Kita tutup dengan strategi zero-downtime deployment dan jebakan konfigurasi yang paling sering menghancurkan produksi.
Sebelum menulis konfigurasi, pahami dulu posisi reverse proxy dalam arsitektur. Reverse proxy adalah gerbang tunggal yang berdiri di depan service-service kalian. Ia menerima seluruh lalu lintas publik dari port 80/443, lalu meneruskan setiap request ke service yang tepat berdasarkan aturan — misalnya domain, path, atau header. Secara mental, anggap ia seperti resepsionis gedung perkantoran: semua tamu masuk lewat satu pintu, lalu resepsionis menentukan ke lantai mana mereka harus diantar. Tanpa resepsionis, setiap lantai harus punya pintu sendiri yang terbuka ke jalan.
Tiga pekerjaan utama reverse proxy:
Host(...) atau prefix path menentukan service tujuan, dan permintaan bisa di-sebar ke beberapa replika yang berjalan sekaligus.Bonus yang tidak kalah penting di era observability: karena semua request melewati satu titik, kalian bisa mengumpulkan akses log terpusat, mengukur latensi, menerapkan rate limiting, atau menambah autentikasi dasar — semuanya tanpa menyentuh kode aplikasi. Itulah mengapa reverse proxy bukan barang mewah, melainkan kebutuhan dasar arsitektur kontainer production. Kita akan membuktikannya lagi di episode 22 ketika merakit arsitektur lengkap.
Prinsip pertama yang harus kalian pegang: jangan pernah publish port aplikasi ke host. Cukup reverse proxy yang mendapat ports. Service lain cukup saling terhubung lewat network Docker menggunakan nama service sebagai DNS. Berikut skema yang akan kita bangun:
Internet
|
[ 80 / 443 ]
|
+------------------+
| reverse proxy | (satu-satunya yang publish port)
| (NGINX/Traefik) |
+------------------+
|
custom bridge network (app-net)
|
+----------+----------+----------+
| | | |
web-app api-app dashboard grafana
:3000 :8000 :8080 :3000Perhatikan bahwa semua service — termasuk reverse proxy — berada di satu custom bridge network. Ini penting karena pada custom bridge, Docker menjalankan embedded DNS: nama service langsung resolve ke IP container. NGINX bisa menulis proxy_pass http://web-app:3000; dan Docker menerjemahkan web-app menjadi alamat IP kontainer yang sedang berjalan, bahkan ketika container itu di-recreate dengan IP baru. Ingat kembali pelajaran episode 9: default bridge tidak punya fitur DNS ini, itulah mengapa di produksi kalian selalu memakai custom bridge.
NGINX adalah pilihan klasik yang memberi kontrol penuh. Kalian menulis file nginx.conf, mem-mount ke container, dan membiarkan NGINX bertindak sebagai resepsionis. Contoh lengkap untuk dua service — web-app (Next.js, port 3000) dan api-app (Express, port 8000) — dengan dukungan WebSocket:
events {}
http {
upstream web_cluster {
server web-app:3000;
}
upstream api_cluster {
server api-app:8000;
}
server {
listen 80;
server_name app.example.com;
location /api/ {
proxy_pass http://api_cluster;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://web_cluster;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
}Block location /api/ meneruskan request API ke cluster backend yang berbeda, sementara location / melayani frontend. Dua baris proxy_set_header Upgrade $http_upgrade; dan Connection "upgrade" adalah kunci agar koneksi WebSocket (misalnya notifikasi real-time) tetap hidup menembus proxy — tanpa keduanya, koneksi WebSocket terputus begitu response pertama terkirim. Ini salah satu detail yang paling sering terlupakan saat pertama kali memasang NGINX di depan aplikasi Node.js.
Beberapa keputusan di compose di atas bukan kebetulan. File nginx.conf di-mount read-only (:ro) — kalian tidak ingin kontainer mengubah konfigurasi proxy. Direktori ./certs menyimpan sertifikat Let's Encrypt. depends_on dengan condition: service_healthy memastikan NGINX baru menerima traffic setelah aplikasi benar-benar siap, mencegah ledakan 502 di menit-menit awal — ingat pelajaran episode 10. Terakhir, networks: app-net menempatkan semua kontainer di bridge yang sama sehingga nama web-app dan api-app bisa di-resolve NGINX.
Tip
NGINX me-resolve nama upstream di konfigurasi hanya saat startup. Jika container aplikasi di-recreate dan mendapat IP baru, NGINX yang lama bisa tetap menyimpan IP usang dan mengirim request ke IP yang tidak ada. Dua solusi: restart container NGINX setelah aplikasi berganti (docker compose restart nginx), atau pakai pola resolver 127.0.0.11 valid=10s + variabel di proxy_pass agar NGINX me-resolve DNS secara berkala.
Jika NGINX seperti resepsionis yang harus diberi daftar tamu secara manual, Traefik adalah resepsionis yang membaca daftar tamu dari lencana yang dipakai setiap orang. Traefik mendengarkan Docker socket dan menemukan service baru secara otomatis — bukan dari file konfigurasi, melainkan dari Docker labels yang menempel pada setiap kontainer. Setiap kali kontainer lahir atau mati, Traefik memperbarui routing tanpa restart. Ditambah integrasi bawaan dengan Let's Encrypt, Traefik adalah pilihan yang sangat produktif untuk deployment single-host seperti milik kita.
Konfigurasi lengkapnya — compose mendefinisikan kontainer dan label routing, sementara acme.toml memegang konfigurasi statis Traefik (entrypoints, provider Docker, dan resolver sertifikat):
services:
traefik:
image: traefik:v3.1
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./acme.toml:/etc/traefik/acme.toml:ro
- ./acme.json:/letsencrypt/acme.json
command:
- --configfile=/etc/traefik/acme.toml
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
web-app:
image: ghcr.io/arman/my-web:1.0.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`app.example.com`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=letsencrypt"
- "traefik.http.services.web.loadbalancer.server.port=3000"
networks:
default:
name: prod-app-netBedah setiap bagian:
[providers.docker] di acme.toml mengaktifkan provider Docker; exposedByDefault = false adalah penjaga keamanan penting — tanpa ini Traefik otomatis mengekspos semua kontainer ke internet. Dengan pengaturan ini, hanya kontainer yang diberi label traefik.enable=true yang dilayani.[entryPoints.web] dan [entryPoints.websecure] mendefinisikan dua pintu masuk: HTTP (80) dan HTTPS (443). Blok redirections membuat semua traffic HTTP otomatis dilompatkan ke HTTPS.[certificatesResolvers.letsencrypt.acme] menghubungkan Traefik dengan Let's Encrypt. Karena memakai tlsChallenge, sertifikat diterbitkan tanpa perlu membuka port tambahan — cukup 80 dan 443.traefik.http.routers.web.rule=Host(...) memberi tahu Traefik: "request dengan header Host app.example.com arahkan ke service web". Label loadbalancer.server.port=3000 memberi tahu port internal tujuan. Traefik otomatis menemukan IP kontainer dari Docker dan melakukan load balancing bila ada beberapa replika.acme.json di-mount dari host. Traefik menulis sertifikat yang diterbitkan ke file ini; karena disimpan di luar kontainer, sertifikat bertahan saat container di-recreate.Important
Setelah membuat acme.json, set izinnya ke 600 (touch acme.json && chmod 600 acme.json). Traefik menolak bekerja dengan file yang world-readable karena acme.json berisi kunci privat. Ini salah satu error paling umum: "permission denied" pada file acme — penyebabnya selalu izin file, bukan password.
Jadi kapan memakai NGINX dan kapan Traefik? NGINX memberi kontrol total — header presisi, caching, rate limiting lanjutan — dan cocok ketika tim sudah fasih NGINX atau butuh penyesuaian mendalam. Traefik unggul dalam kemudahan: menambah service baru cukup dengan label, tanpa menyentuh konfigurasi proxy; sertifikat TLS diperbarui otomatis. Untuk single-host dengan banyak service yang sering berubah, Traefik hampir selalu lebih produktif. Kita akan memakai Traefik lagi di studi kasus episode 22.
Reverse proxy saja belum cukup; kalian juga harus bisa merilis versi baru tanpa memutus koneksi pengguna. Cara naif — docker compose down lalu up — mematikan seluruh stack dan membuat website offline beberapa detik. Di produksi, downtime sekecil itu bisa berarti penjualan hilang atau skor SEO terpukul.
Strategi paling sederhana yang sering dipakai di dunia nyata adalah rolling restart dengan --scale + healthcheck:
# 1. Bangun image versi baru
docker build -t ghcr.io/arman/my-web:2.0.0 .
# 2. Perbesar replika sementara agar kapasitas aman selama rotasi
docker compose up -d --no-deps --scale web-app=3 --wait
# 3. Terapkan image baru dengan recreate (healthcheck memandu NGINX)
docker compose up -d --no-deps --scale web-app=3 --build
# 4. Kembalikan ke jumlah normal
docker compose up -d --no-deps --scale web-app=2 --waitLogikanya begini: ketika sebuah container di-recreate, NGINX berhenti mengarahkan traffic ke container yang tidak sehat dan mengalihkannya ke replica yang tersisa. Container baru melewati start_period dan healthcheck dulu sebelum dianggap sehat kembali. Selama proses ini selalu ada minimal satu replica sehat yang melayani permintaan. Syaratnya: aplikasi harus menangani shutdown secara graceful — menangkap sinyal SIGTERM, berhenti menerima koneksi baru, menyelesaikan koneksi yang sedang berjalan, baru keluar. Aplikasi yang langsung process.exit() begitu dapat sinyal akan tetap memutus request aktif.
Jika kalian sudah memakai Docker Swarm (episode 16–17), rolling update bawaan jauh lebih rapi karena orkestrator yang mengatur urutannya:
docker service update \
--image ghcr.io/arman/my-web:2.0.0 \
--update-parallelism 1 \
--update-delay 10s \
--update-order start-first \
--update-failure-action rollback \
--health-cmd "wget -qO- http://localhost:3000/health" \
--health-interval 10s \
--health-start-period 20s \
web--update-order start-first berarti task baru dibuat dan dinyatakan sehat sebelum task lama dihentikan — persis pola yang kita lakukan manual, tapi dikelola Swarm. Jika healthcheck task baru gagal, --update-failure-action rollback mengembalikan image ke versi lama secara otomatis. Ini alasan kuat kenapa arsitektur production multi-host sebaiknya memakai orkestrator, bukan sekadar Compose.
Menutup episode 20, mari bedah jebakan yang paling sering muncul saat reverse proxy masuk produksi:
Header proxy yang salah. Tanpa proxy_set_header Host $host;, aplikasi melihat Host internal (misalnya web-app:3000) sehingga URL yang di-generate aplikasi salah. Tanpa X-Forwarded-Proto $scheme, aplikasi mengira koneksi masih HTTP dan melakukan redirect berulang ke HTTPS (redirect loop). Ingat: reverse proxy memutus TLS, jadi aplikasi tidak tahu koneksi asli aman kecuali fakta itu diteruskan lewat header. Di sisi aplikasi, framework perlu dipercaya: Express misalnya harus dipanggil app.set("trust proxy", true) agar membaca header itu — jika tidak, log alamat IP pengguna akan selalu berisi IP proxy.
Urutan startup container. Jika NGINX/Traefik jalan lebih dulu sedangkan aplikasi belum siap, pengguna melihat 502. Solusinya depends_on dengan condition: service_healthy di Compose, atau memastikan proxy punya retry. Di Swarm, healthcheck adalah bagian dari definisi service sehingga masalah ini tidak muncul.
Renewal sertifikat TLS. Traefik memperbarui sertifikat secara otomatis, selama acme.json tersimpan di persistent volume. Pada NGINX + certbot, renewal harus diikuti reload NGINX — jika hanya memperbarui file sertifikat tanpa nginx -s reload, NGINX tetap memegang sertifikat lama di memori sampai di-restart. Tambahkan hook --deploy-hook "docker exec nginx nginx -s reload" pada certbot, atau jadwalkan restart container NGINX saat sertifikat berganti.
Ekspos port ganda. ports dipasang pada aplikasi dan proxy. Ini membuat aplikasi bisa diakses langsung dari luar, melewati semua kebijakan proxy (rate limit, logging, TLS). Aturan praktis: hanya proxy yang mendapat ports; aplikasi cukup hidup di network internal.
Pada episode 20 ini kita telah membangun jembatan terakhir menuju produksi: memahami mengapa reverse proxy wajib ada (satu IP, dua port, TLS termination, routing), membedah dua implementasi — NGINX dengan upstream manual dan header proxy yang presisi, serta Traefik yang menemukan service dari Docker labels dan memperbarui sertifikat Let's Encrypt sendiri — dan menyusun strategi zero-downtime deployment lewat rolling restart dengan --scale + healthcheck maupun docker service update di Swarm. Kita juga mengidentifikasi empat pitfall klasik: header Host/X-Forwarded-* yang salah, urutan startup, renewal TLS, dan port yang bocor.
Inti yang harus kalian bawa:
proxy_set_header Host dan X-Forwarded-Proto wajib benar, dan aplikasi harus trust proxy.acme.json harus chmod 600, dan renewal TLS perlu reload proxy.Di episode 21 selanjutnya kita berbalik dari produksi ke meja kerja: Docker for Development — hot reload tanpa rebuild, debugging kontainer dengan docker exec/docker diff/docker stats, VS Code Dev Containers, dan docker compose watch. Sekarang kalian tahu cara mendeploy dengan benar; episode berikutnya memastikan pengembangan sebelum deploy sama mulusnya. Sampai jumpa di episode 21!