Nginx reverse proxy with Docker Compose: in a container or on the host
Put Nginx in front of Docker Compose services: on the host or in a container, connect by service name, fix "host not found in upstream", keep ports private, add HTTPS.
by OhSudo ·
Part of a learning path:Web servers: nginx and HTTPS in production
There are two good ways to put Nginx in front of your containers, and it’s worth choosing on purpose:
- Nginx on the host, with containers published on
127.0.0.1. It’s simple, and fits a server that already runs Nginx, or Certbot. - Nginx in a container, in the same Compose file. Everything lives in the project, and containers connect by name.
Option 1: Nginx on the host
Publish the app only on localhost, so that it’s not reachable from outside:
services: app: image: myapp:1.0 restart: unless-stopped ports: - "127.0.0.1:8080:3000"That’s the important part. Without 127.0.0.1, the port is open to the internet, and Docker’s rules go around ufw. Then Nginx proxies to it, as for any local app:
server { listen 80; listen [::]:80; server_name example.com; return 301 https://example.com$request_uri;}
server { listen 443 ssl; listen [::]:443 ssl; server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
location / { proxy_pass http://127.0.0.1:8080; 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; }}The Nginx Config Generator with these options writes this for your domain. Certificates work as usual, with Certbot on the host.
Option 2: Nginx in a container
services: nginx: image: nginx:1.27-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - /etc/letsencrypt:/etc/letsencrypt:ro depends_on: - app
app: image: myapp:1.0 restart: unless-stopped # No ports: only nginx can reach it, by name.Only nginx publishes ports. Compose puts all services on one network, where each can reach the others by service name, so the app’s port stays private. The Docker Compose Generator can write the rest of the file, with healthchecks and volumes.
Here’s nginx.conf. Note the name app, and that the port is the one inside the container:
server { listen 80; server_name example.com; return 301 https://example.com$request_uri;}
server { listen 443 ssl; server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
location / { proxy_pass http://app:3000; 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; }}Apply changes
docker compose up -ddocker compose exec nginx nginx -tdocker compose exec nginx nginx -s reloaddocker compose logs -f nginxA reload picks up a changed nginx.conf, as long as you’ve mounted the file. Some editors replace a file rather than change it, which leaves the container with the old one, so if a reload changes nothing, use docker compose restart nginx.
“host not found in upstream”
nginx: [emerg] host not found in upstream "app" in /etc/nginx/conf.d/default.conf:15This common error means the name didn’t resolve when Nginx started. The container then exits, and restarts in a loop. Check these:
- Is the name right? It’s the name under
services:, notcontainer_name, nor the image. - Are they on the same network?
docker network inspectshows who’s on it. Services in different Compose projects need a sharedexternalnetwork. - Is
apprunning? If it’s crashing, its name doesn’t resolve.docker compose psanddocker compose logs apptell you.
depends_on only waits for the container to start. With condition: service_healthy and a healthcheck, it waits until the app is ready.
Nginx keeps the old address
Nginx resolves names in proxy_pass once, at startup. If app is recreated, it may get a new IP address, and Nginx keeps calling the old one, giving you a 502. Either reload it after each change, or make it look the name up each time, using Docker’s own DNS:
location / { resolver 127.0.0.11 valid=30s; set $upstream http://app:3000; proxy_pass $upstream; proxy_set_header Host $host;}It also means that Nginx starts even if app is down, and just answers 502 until it’s back.
Certificates in a container
For certificates, Certbot needs port 80, or a shared folder. If Nginx is in a container, the simplest way is keeping Certbot on the host:
sudo certbot certonly --standalone -d example.com \ --pre-hook "docker compose -f /srv/app/compose.yaml stop nginx" \ --post-hook "docker compose -f /srv/app/compose.yaml start nginx"That’s what the /etc/letsencrypt volume above is for. Mount the whole folder, as live/ holds links into archive/, and a mount of just live/ leaves broken links.
When it goes wrong
| You see | Means | Go to |
|---|---|---|
502 Bad Gateway |
The app is down, a wrong port, or an old IP | Nginx 502 |
connect() failed (111: Connection refused) from 127.0.0.1 in a container |
localhost there is Nginx’s own container |
Connecting to localhost from a container |
port is already allocated |
Something else has 80 or 443 | Port already allocated |
504 Gateway Time-out |
The app takes too long | Nginx 504 |
413 on uploads |
Set client_max_body_size |
Nginx 413 |
The Port Lookup tells you what normally runs on a port.
Warning
Don’t publish databases or caches in the same file, as in 5432:5432. Published ports go around ufw, and reach the internet on a server with a public address. Those services need no ports: at all, since your app reaches them by name.
Found this useful? Share it.
Found a mistake? Let us know at contact@ohsudo.com.