Why containerized applications in production need a reverse proxy: how a single public IP with ports 80/443 serves many services, a comparison of manual NGINX with Traefik doing dynamic routing and automatic TLS via Let's Encrypt, plus zero-downtime deployment strategies and their pitfalls.

After discussing Docker integration in CI/CD pipelines in episode 19 — images built, scanned, and pushed to a registry on every new commit — you now have a ready-to-use image at ghcr.io. But an image idling in a registry isn't a running application. This episode is the final bridge to production: how to deliver that image to users through a single public entry point, complete with HTTPS, without opening a dozen unnecessary ports to the internet.
This is where many beginner teams first feel the difference between "can run on Docker" and "fit for production". Imagine three services: web, API, and an admin dashboard. Without planning, many are tempted to run everything with -p 3000:3000, -p 3001:3001, -p 8080:8080 — then open all those ports in the firewall. Yet you only have one public IP and two ports users find easiest to remember: 80 and 443. The answer to this dead end is a reverse proxy, and in this episode you'll master its two most common implementations in the Docker world: NGINX, configured manually, and Traefik, which discovers services dynamically and updates TLS certificates itself. We close with zero-downtime deployment strategies and the configuration traps that most often destroy production.
Before writing configuration, understand the reverse proxy's position in the architecture. A reverse proxy is the single gateway standing in front of your services. It receives all public traffic on ports 80/443, then forwards each request to the right service based on rules — for example domain, path, or header. Mentally, think of it like an office building's receptionist: all guests enter through one door, then the receptionist decides which floor they should be taken to. Without a receptionist, every floor must have its own door opening to the street.
Three main jobs of a reverse proxy:
Host(...) or path-prefix rules determine the destination service, and requests can be spread across several running replicas.A bonus just as important in the observability era: since all requests pass through a single point, you can collect centralized access logs, measure latency, apply rate limiting, or add basic authentication — all without touching application code. That's why a reverse proxy isn't a luxury, but a basic requirement of a production container architecture. We'll prove it again in episode 22 when assembling a complete architecture.
The first principle you must hold onto: never publish application ports to the host. Only the reverse proxy gets ports. Other services just connect to each other through a Docker network using the service name as DNS. Here's the scheme we'll build:
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 :3000Notice that all services — including the reverse proxy — are on one custom bridge network. This matters because on a custom bridge, Docker runs embedded DNS: service names resolve directly to container IPs. NGINX can write proxy_pass http://web-app:3000; and Docker translates web-app into the running container's IP address, even when that container is recreated with a new IP. Remember the episode 9 lesson: the default bridge doesn't have this DNS feature, which is why you always use a custom bridge in production.
NGINX is the classic choice giving full control. You write the nginx.conf file, mount it into the container, and let NGINX act as the receptionist. A complete example for two services — web-app (Next.js, port 3000) and api-app (Express, port 8000) — with WebSocket support:
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;
}
}
}The location /api/ block forwards API requests to a different backend cluster, while location / serves the frontend. The two lines proxy_set_header Upgrade $http_upgrade; and Connection "upgrade" are key to keeping WebSocket connections (e.g. real-time notifications) alive through the proxy — without them, a WebSocket connection drops the moment the first response is sent. It's one of the most commonly forgotten details when first putting NGINX in front of a Node.js application.
Some decisions in the compose above aren't coincidental. The nginx.conf file is mounted read-only (:ro) — you don't want the container changing the proxy configuration. The ./certs directory stores Let's Encrypt certificates. depends_on with condition: service_healthy ensures NGINX only takes traffic after the applications are truly ready, preventing an explosion of 502s in the first minutes — remember the episode 10 lesson. Finally, networks: app-net places all containers on the same bridge so NGINX can resolve web-app and api-app.
Tip
NGINX resolves upstream names in its configuration only at startup. If an application container is recreated and gets a new IP, an old NGINX can keep the stale IP and send requests to an address that no longer exists. Two solutions: restart the NGINX container after the application changes (docker compose restart nginx), or use the resolver 127.0.0.11 valid=10s pattern + a variable in proxy_pass so NGINX resolves DNS periodically.
If NGINX is like a receptionist who must be given a guest list manually, Traefik is a receptionist who reads the guest list from the badge everyone wears. Traefik listens to the Docker socket and discovers new services automatically — not from a config file, but from Docker labels attached to each container. Every time a container is born or dies, Traefik updates routing without a restart. Combined with built-in Let's Encrypt integration, Traefik is a very productive choice for single-host deployments like ours.
The full configuration — compose defines the containers and routing labels, while acme.toml holds Traefik's static config (entrypoints, Docker provider, and certificate resolver):
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-netDissecting each part:
[providers.docker] in acme.toml enables the Docker provider; exposedByDefault = false is an important security guard — without it Traefik automatically exposes every container to the internet. With this setting, only containers labeled traefik.enable=true are served.[entryPoints.web] and [entryPoints.websecure] define the two doors: HTTP (80) and HTTPS (443). The redirections block makes all HTTP traffic automatically jump to HTTPS.[certificatesResolvers.letsencrypt.acme] connects Traefik with Let's Encrypt. Because it uses tlsChallenge, certificates are issued without needing any extra open ports — just 80 and 443.traefik.http.routers.web.rule=Host(...) tells Traefik: "requests with the Host header app.example.com go to service web". The loadbalancer.server.port=3000 label tells it the internal destination port. Traefik automatically finds the container IP from Docker and load balances when there are several replicas.acme.json is mounted from the host. Traefik writes issued certificates to this file; because it's stored outside the container, certificates survive container recreates.Important
After creating acme.json, set its permissions to 600 (touch acme.json && chmod 600 acme.json). Traefik refuses to work with a world-readable file because acme.json contains private keys. This is one of the most common errors: "permission denied" on the acme file — the cause is always the file permission, not a password.
So when to use NGINX and when Traefik? NGINX gives total control — precise headers, caching, advanced rate limiting — and fits when the team is already fluent in NGINX or needs deep customization. Traefik excels in ease: adding a new service is just labels, without touching proxy config; TLS certificates are renewed automatically. For a single host with many frequently changing services, Traefik is almost always more productive. We'll use Traefik again in the episode 22 case study.
A reverse proxy alone isn't enough; you must also be able to release a new version without cutting user connections. The naive way — docker compose down then up — kills the entire stack and takes the website offline for a few seconds. In production, even that small a downtime can mean lost sales or a hit to SEO scores.
The simplest strategy commonly used in the real world is a rolling restart with --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 --waitThe logic: when a container is recreated, NGINX stops routing traffic to the unhealthy container and diverts it to the remaining replicas. The new container passes its start_period and healthcheck before being considered healthy again. Throughout this process there's always at least one healthy replica serving requests. The requirement: the application must handle graceful shutdown — catch the SIGTERM signal, stop accepting new connections, finish in-flight connections, then exit. An application that immediately process.exit()s on signal will still cut active requests.
If you're already using Docker Swarm (episodes 16-17), the built-in rolling update is far cleaner because the orchestrator manages the sequence:
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 means new tasks are created and declared healthy before old tasks are stopped — exactly the pattern we did manually, but managed by Swarm. If the new task's healthcheck fails, --update-failure-action rollback automatically returns the image to the old version. This is a strong reason why multi-host production architectures should use an orchestrator, not just Compose.
Closing episode 20, let's dissect the traps that most often appear when a reverse proxy goes to production:
Wrong proxy headers. Without proxy_set_header Host $host;, the application sees an internal Host (e.g. web-app:3000), so the URLs the application generates are wrong. Without X-Forwarded-Proto $scheme, the application thinks the connection is still HTTP and does repeated redirects to HTTPS (a redirect loop). Remember: the reverse proxy terminates TLS, so the application doesn't know the original connection is secure unless that fact is forwarded via headers. On the application side, the framework must trust the proxy: Express for example must call app.set("trust proxy", true) to read those headers — otherwise the user IP logs will always contain the proxy IP.
Container startup order. If NGINX/Traefik starts first while the application isn't ready, users see 502s. The solution is depends_on with condition: service_healthy in Compose, or making sure the proxy has retries. In Swarm, the healthcheck is part of the service definition so this problem doesn't arise.
TLS certificate renewal. Traefik renews certificates automatically, as long as acme.json is stored on a persistent volume. On NGINX + certbot, renewal must be followed by an NGINX reload — if you only renew the certificate files without nginx -s reload, NGINX keeps the old certificate in memory until restarted. Add the hook --deploy-hook "docker exec nginx nginx -s reload" to certbot, or schedule a NGINX container restart when the certificate changes.
Double port exposure. ports is attached to both the application and the proxy. This makes the application directly reachable from outside, bypassing every proxy policy (rate limit, logging, TLS). Rule of thumb: only the proxy gets ports; applications just live on the internal network.
In this episode 20 we built the final bridge to production: understanding why a reverse proxy is mandatory (one IP, two ports, TLS termination, routing), dissecting two implementations — NGINX with manual upstreams and precise proxy headers, and Traefik discovering services from Docker labels and renewing Let's Encrypt certificates itself — and building zero-downtime deployment strategies via rolling restarts with --scale + healthcheck or docker service update in Swarm. We also identified four classic pitfalls: wrong Host/X-Forwarded-* headers, startup order, TLS renewal, and leaking ports.
Core takeaways:
proxy_set_header Host and X-Forwarded-Proto must be correct, and the application must trust the proxy.acme.json must be chmod 600, and TLS renewal needs a proxy reload.In the next episode, episode 21, we turn from production back to the workbench: Docker for Development — hot reload without rebuilds, debugging containers with docker exec/docker diff/docker stats, VS Code Dev Containers, and docker compose watch. Now you know how to deploy properly; the next episode ensures development before deploying is just as smooth. See you in episode 21!