Skip to main content
RunBook Academy

Docker & ContainersXXIV · Reverse Proxies & TLSnginx

nginx as a Docker reverse proxy

Intermediate⏱ ~26 mindockernginx

What you'll learn

  • Write an nginx reverse-proxy configuration that survives a container recreation
  • Explain why a container name in `proxy_pass` stops resolving after a redeploy, and fix it
  • Distinguish a 502 from a 504 and name the timeout responsible for each
  • Configure passive upstream health checking and know what nginx OSS cannot do
  • Terminate TLS at the proxy and drain a backend without dropping requests

Prerequisites

None — start here.

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

nginx is the workhorse reverse proxy: stable, fast, and older than containers by a decade. That last part is the one that matters here. nginx has no idea Docker exists. It reads a text file, resolves the names in it, and serves. Every operational surprise in this lesson comes from that single fact.

The configuration below is the one to start from. The sections after it are the four things it does that a three-line example does not, and the one failure that will find you anyway.

Where nginx runs, and what changes

nginx in a containernginx on the host
Reaches backends byCompose service name on a shared networkpublished port on 127.0.0.1
Backend sees client asthe nginx container’s addressthe bridge gateway, e.g. 172.17.0.1
Certificate lives ona mounted volume or bind mountthe host filesystem
Restarted bydocker compose restart proxysystemctl reload nginx
Fails whenthe backend container is recreatedthe published port moves

Both are legitimate. In a container is the common choice because the proxy then travels with the stack; on the host is the choice when nginx fronts things that are not containers as well. This lesson assumes the container case and flags where the host case differs.

A configuration that survives production

Configuration change/etc/nginx/conf.d/app.conf
# Docker's embedded DNS. Only reachable from a container attached to a
# user-defined network - it does not exist on the host.
resolver 127.0.0.11 valid=10s ipv6=off;

upstream app_backend {
  # 'zone' puts the upstream state in shared memory, which is what
  # makes 'resolve' and runtime state possible. Open source since 1.9.0.
  zone app_backend 64k;

  # 'resolve' re-resolves the name in the background on the TTL above,
  # instead of once at startup. Open source since nginx 1.27.3.
  server web:8080 resolve max_fails=3 fail_timeout=10s;

  # Reuse upstream connections. Requires HTTP/1.1 and an empty
  # Connection header on the proxy_pass side, set below.
  keepalive 16;
}

server {
  listen 443 ssl;
  http2 on;
  server_name app.example.com;

  ssl_certificate     /etc/nginx/certs/app.example.com/fullchain.pem;
  ssl_certificate_key /etc/nginx/certs/app.example.com/privkey.pem;
  ssl_protocols       TLSv1.2 TLSv1.3;
  ssl_session_cache   shared:SSL:10m;

  # Trust the forwarded address only from the addresses that are
  # genuinely in front of us. See the client-identity lesson.
  set_real_ip_from 192.0.2.0/24;
  real_ip_header   X-Forwarded-For;
  real_ip_recursive on;

  # Bound the time nginx will wait on the client, separately from
  # the time it will wait on the backend.
  client_max_body_size 20m;
  client_body_timeout  30s;

  location / {
      proxy_pass http://app_backend;

      proxy_http_version 1.1;
      proxy_set_header Connection "";

      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 X-Forwarded-Host  $host;

      proxy_connect_timeout 5s;
      proxy_send_timeout    30s;
      proxy_read_timeout    30s;

      # Do not silently replay a POST against a second backend.
      proxy_next_upstream error timeout http_502 http_503;
      proxy_next_upstream_tries 2;
  }

  # The proxy's own liveness, not the backend's. Answering this
  # proves nginx is up and says nothing about the application.
  location = /nginx-health {
      access_log off;
      return 200 "nginx ok\n";
  }
}

server {
  listen 80;
  server_name app.example.com;
  return 308 https://$host$request_uri;
}

Three of those lines are the difference between this and the example everyone copies: resolve on the server line, the explicit timeouts, and proxy_next_upstream_tries 2. Each has a section below.

The failure that defines nginx-in-Docker

The fix is to make nginx re-resolve. There are two forms, and which one you can use depends on your nginx version.

Configuration changetwo ways to re-resolve
# 1. nginx 1.27.3 and later: 'resolve' on the upstream server line.
#    Keeps load balancing, keepalive and passive health checks.
resolver 127.0.0.11 valid=10s ipv6=off;

upstream app_backend {
  zone app_backend 64k;
  server web:8080 resolve;
  keepalive 16;
}

# 2. Older nginx: put the name in a variable. Using any variable in
#    proxy_pass defers resolution to request time.
#    Cost: no upstream block, so no keepalive and no passive health
#    checking. A per-request DNS lookup instead.
resolver 127.0.0.11 valid=10s ipv6=off;

location / {
  set $backend "web:8080";
  proxy_pass http://$backend;
}

Form 2 has a second, quieter cost. Because the address is a variable, nginx passes the request URI through unchanged rather than performing the location-prefix substitution — which is what you want for location /, and a behaviour change you must account for anywhere you were relying on proxy_pass rewriting the path.

Read-only / Safeprove the resolver
PROXY=proxy
BACKEND=web

# Is the embedded DNS present in this container at all?
docker compose exec "$PROXY" cat /etc/resolv.conf

# What does the name resolve to right now?
docker compose exec "$PROXY" getent hosts "$BACKEND"

# What address is the container actually on?
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$BACKEND"

If the last two disagree, you have found the 502.

Health checking upstreams: what nginx OSS will not do

This is the most commonly misstated thing about nginx, and the correction is worth stating plainly.

Active health checking — nginx polling a backend on a timer, independently of client traffic — is a commercial (nginx Plus) feature. Open-source nginx has passive health checking only: it learns a backend is bad by failing real client requests against it.

Configuration changepassive health checking
upstream app_backend {
  zone app_backend 64k;
  resolver 127.0.0.11 valid=10s;

  # 3 failures inside 10s take this server out for 10s.
  server web-a:8080 resolve max_fails=3 fail_timeout=10s;
  server web-b:8080 resolve max_fails=3 fail_timeout=10s;

  # A server marked 'backup' only receives traffic when every
  # primary is marked down.
  server web-standby:8080 resolve backup;

  keepalive 16;
}

The practical meaning: the first max_fails requests after a backend dies are served an error to a real user. Passive checking cannot be otherwise — the failure is the detection. Sizing max_fails is a trade between how many users see the error and how easily a single slow request evicts a healthy backend.

If you need genuine active checks without a subscription, the answer is not an nginx directive. It is either a different proxy — HAProxy and Traefik both do active checks in their open-source builds — or an external agent that rewrites the upstream file and reloads nginx.

502 versus 504, and the timeout that causes each

These two status codes are nginx telling you two very different things, and reading them correctly saves the first twenty minutes of an incident.

Responsenginx sawUsual cause
502 Bad Gatewayconnection refused, reset, or an empty/invalid responsebackend down, backend crashed mid-response, stale upstream address, keepalive race
504 Gateway Time-outnothing at all, until a timeout firedbackend alive but too slow, or blocked on its own dependency
503 Service Unavailableno upstream server available to tryevery backend marked down by passive checks, or limit_req rejected it

The timeout defaults are all 60s and all three measure different things:

  • proxy_connect_timeout (60s) — establishing the TCP connection. Should be small; a backend on the same bridge either accepts in milliseconds or is not there. 5s is generous.
  • proxy_send_timeout (60s) — between two successive write operations while sending the request body.
  • proxy_read_timeout (60s)between two successive read operations, not the total response time. A backend that streams a byte every 20 seconds for an hour never trips a 30-second read timeout. This is the directive people believe is a total-response deadline; it is not.

Draining a backend on deploy

nginx OSS has no runtime API, so draining means editing the upstream and reloading. The reload is the graceful part.

Service impact possibledrain one backend
set -euo pipefail
UPSTREAM=/etc/nginx/conf.d/upstream.conf
TARGET=web-a

# 1. Mark it down. 'down' stops new requests; it does not cut existing ones.
sudo sed -i "s|^\(\s*server $TARGET:8080\)[^;]*;|\1 down;|" "$UPSTREAM"

# 2. Validate, then reload. Old workers finish their current requests.
sudo nginx -t && sudo nginx -s reload

# 3. Prove it is idle before you touch it.
docker compose exec "$TARGET" sh -c 'ss -tn state established | wc -l'

A reload is genuinely graceful: the master process starts new worker processes with the new configuration, stops sending them new connections, and lets the old workers finish what they already accepted before exiting. worker_shutdown_timeout bounds how long it will wait for a worker that will not finish — the default is no limit, which means one WebSocket can keep an old worker alive indefinitely. Set it if you have long-lived connections.

TLS termination and where the certificate lives

nginx does not speak ACME. It reads two files. Something else has to put them there and reload nginx when they change — certbot, a cert-manager-style job, or a sidecar container.

Configuration changecert placement for a containerised nginx
cat <<'YAML' > compose.override.yaml
services:
proxy:
  volumes:
    - /etc/letsencrypt:/etc/nginx/certs:ro
YAML

# The renewal hook. certbot runs this after a successful renewal.
cat <<'SH' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/usr/bin/env bash
set -euo pipefail
docker compose -f /srv/app/compose.yaml exec -T proxy nginx -t
docker compose -f /srv/app/compose.yaml exec -T proxy nginx -s reload
SH
sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

The :ro matters. A proxy that can write to your certificate store is a proxy that can replace your certificate if it is compromised, and it has no reason to.

Verification that can fail

Read-only / Safewhat is actually being served
$ openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -fingerprint -sha256
notBefore=Jul 22 04:11:07 2026 GMT
notAfter=Oct 20 04:11:06 2026 GMT
sha256 Fingerprint=A1:B2:C3:D4:E5:F6:07:18:29:3A:4B:5C:6D:7E:8F:90:A1:B2:C3:D4:E5:F6:07:18:29:3A:4B:5C:6D:7E:8F:90

Illustrative output

Read-only / Safefour checks that distinguish working from broken
HOST=app.example.com
PROXY=proxy
BACKEND=web

# 1. Configuration parses. Non-zero exit is the answer.
docker compose exec -T "$PROXY" nginx -t

# 2. Fingerprint on disk matches fingerprint on the wire.
docker compose exec -T "$PROXY" openssl x509 -in /etc/nginx/certs/live/"$HOST"/fullchain.pem -noout -fingerprint -sha256

# 3. The name the proxy has cached resolves to the container that exists.
docker compose exec -T "$PROXY" getent hosts "$BACKEND"
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$BACKEND"

# 4. The backend sees a forwarded address, not the proxy's own.
curl -sS -o /dev/null -w '%{http_code}\n' "https://$HOST/"
docker compose logs --since 30s --no-log-prefix "$BACKEND" | tail -n 5

Check 4 is the one people skip. If the backend’s access log shows the proxy’s container address on every line rather than a real client address, the forwarded headers are not being read — which means your rate limiting, your allowlists and your audit trail are all keyed on a constant.

When nginx is the right choice

nginxHAProxyTraefikCaddy
Discoverystatic filestatic fileDocker labelsstatic file
Active health checkscommercial onlyyesyesyes
ACME built innonoyesyes
L4 (TCP)stream modulefirst-classlimitedlayer4 plugin
Runtime state change without reloadnoyes (Runtime API)n/ayes (admin API)
Also a web server / static filesyesnonoyes

Choose nginx when the backend set is stable, when you need it to serve static content or do caching as well as proxy, or when the team already reads nginx configuration fluently — which is a real operational property and not a soft one. Choose against nginx when containers are created and destroyed frequently, because that is the case its static model fits worst.

Knowledge check

Knowledge check · 4 questions

  1. Q1. After `docker compose up -d` recreates the app container, nginx returns 502 for every request, but `docker compose exec proxy curl http://web:8080/` succeeds. What is wrong?

  2. Q2. A backend takes 45 seconds to respond, sending nothing until it is finished. nginx has `proxy_read_timeout 30s`. What does the client receive?

  3. Q3. Which statements about open-source nginx in front of containers are correct? Select all that apply.

  4. Q4. Marking an upstream server `down` and deleting its server line have equivalent effects on the upstream.

Passing score: 75%. Answers are checked in this browser.

Where next

HAProxy is the next lesson, and it is the same job with the opposite trade: a runtime API and real active health checks, in exchange for a configuration language that assumes you already know load balancing.