Docker Compose: Nginx as a reverse proxy
Put an app behind Nginx with Docker Compose: internal network, proxy_pass to the service name, healthcheck and how to diagnose a 502 Bad Gateway. Includes when to use Traefik or Caddy instead.
A reverse proxy puts a single front door in front of one or more apps: the client talks to Nginx and Nginx forwards the request to the right service over the internal network. This guide sets up the minimum to understand it (Nginx in front of an app) and spends most of the time on the hard part: diagnosing a 502.
compose.yaml: app behind Nginx
The app does not publish a port: it only exposes it to the internal network. Nginx is the only one that publishes a port to the host, and bound to loopback.
services:
app:
build: .
restart: unless-stopped
expose:
- "3000"
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost/ || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20sAnd the nginx.conf we mount at conf.d/default.conf (a single server block; the image already ships the http wrapper):
server {
listen 80;
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}The key: proxy_pass to the service name
proxy_pass http://app:3000; is what does the proxying. app is the service name in the compose, and 3000 the internal port it listens on. It is not localhost: inside the Nginx container, localhost would be Nginx itself. The X-Forwarded-* headers tell your app who the original client was and whether it came over HTTP or HTTPS.
Diagnosing a 502 Bad Gateway
A 502 means Nginx is alive but cannot talk to the app. Work through it in order:
- Are the name and port right?
proxy_passmust point at the exact service name and the internal port (app:3000), not the host-published port. - Is the app up?
docker compose psanddocker compose logs app. If it crashed or is still starting, Nginx has nothing to forward to. - Does the app listen on 0.0.0.0? If your app listens on
127.0.0.1inside the container, it only accepts itself; Nginx cannot get in. It must listen on0.0.0.0(all container interfaces). - Test from inside:
docker compose exec nginx wget -qO- http://app:3000tells you whether Nginx reaches the app over the internal network.
TLS and when to switch tools
Here we serve plain HTTP: TLS (certificates, 443 redirect, renewal) is out of scope. Hand-written Nginx is transparent and great for learning or a simple proxy. If you manage many dynamic services or want automatic HTTPS without fighting config, look at Caddy (automatic TLS, minimal config) or Traefik(dynamic label-based discovery). The choice depends on how much automation you need, not on one being "better" in the abstract.
Next steps
Get the starting Nginx service in the Docker Compose generator and head back to the examples index to see how it fits with your app or database.
Frequently asked questions
- I get 502 Bad Gateway, what does it mean?
- It means Nginx received your request but could not talk to the upstream (your app). Common causes: the name or port in proxy_pass does not match the real service (it must be http://app:3000, the service name and the internal port); the app has not started yet or crashed; or the app listens on 127.0.0.1 inside its container instead of 0.0.0.0, so it does not accept connections from Nginx. Check docker compose logs app and docker compose ps.
- Why do I use 'expose' on the app and 'ports' only on Nginx?
- Because the app does not need to be reachable from the host: only Nginx talks to it, over the internal network. expose: 3000 documents the internal port without publishing it. Nginx is the only one that publishes a port to the host (127.0.0.1:8080:80). That way all ingress goes through the proxy and you do not expose the app directly.
- Why does proxy_pass point to 'app' and not 'localhost'?
- Because inside the Nginx container, localhost is Nginx itself. The app is another container and is resolved by its service name on the Compose internal network: http://app:3000. It is the same principle as connecting an app to postgres:5432 by name.
- Does this include HTTPS/TLS?
- No, and that is on purpose. This guide covers the reverse proxy over plain HTTP for local development. TLS adds certificates, 80 to 443 redirects and renewal, which are out of scope here. If you need HTTPS with little effort, Caddy obtains and renews certificates automatically and Traefik integrates them with Let's Encrypt.
- When should I use Traefik or Caddy instead of hand-written Nginx?
- Nginx with a hand-written nginx.conf is transparent and perfect for learning or a simple proxy. If you have many services that come and go, or you want automatic TLS and label-based routing without editing config, Traefik (dynamic discovery) or Caddy (automatic TLS, minimal config) usually mean less friction. There is no single answer: it depends on how much automation you need.