nginx as a Reverse Proxy in a Container: Wiring Up Multiple Upstreams
A working minimal example – nginx via Docker Compose in front of three backend containers, with upstream blocks, path-based routing and correct header forwarding – plus the three traps around DNS, headers and WebSockets.
As soon as more than one service runs on a server, you need something that accepts incoming requests and hands them to the right container. There are the convenient candidates for that job – I have already written about Caddy and about Caddy vs. Traefik – and there is the classic: nginx with a hand-written configuration. That is precisely where its appeal lies. Every routing decision, every header and every upstream is spelled out in a text file instead of having to be reconstructed from container labels. This post stands up a minimal but complete nginx proxy in front of three backend containers in about fifteen minutes, and walks through the core idea once: upstream group, server block, proxy_pass, headers.
What a Reverse Proxy Does – and the Core Idea
Three building blocks carry the entire configuration.
An upstream block defines a named group of backend servers. It lives in the http context and is later referenced by name alone. If it contains several server lines, nginx distributes requests using weighted round-robin according to the documentation – that is your load balancing, without having to enable anything.
A server block is the virtual host: listen and server_name decide which requests it accepts. Inside it, location blocks select the responsible upstream based on the path, and proxy_pass sends the request there.
The third building block is invisible and still decisive: the shared Docker network. All containers sit on a user-defined network, and only there does Docker's embedded DNS server at 127.0.0.11 resolve container and service names to IP addresses. On the old default bridge network this explicitly does not work according to the Docker documentation – containers can only reach each other by IP there. That is why the Compose file declares its own network.
The Stack: compose.yaml
For the backends we use nginxdemos/hello:plain-text three times. The image returns its hostname, IP and request URI as plain text, so you can immediately see which container answered. In your real setup, your own services go there.
services:
proxy:
image: nginx:1.30.4-alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app1
- app2
- api
restart: unless-stopped
networks: [edge]
app1:
image: nginxdemos/hello:plain-text
restart: unless-stopped
networks: [edge]
app2:
image: nginxdemos/hello:plain-text
restart: unless-stopped
networks: [edge]
api:
image: nginxdemos/hello:plain-text
restart: unless-stopped
networks: [edge]
networks:
edge:
The official nginx image already includes /etc/nginx/conf.d/*.conf from inside the http block. Our file therefore lands exactly where map and upstream are allowed – without touching the main nginx.conf.
The Configuration: nginx.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream app_upstream {
server app1:80;
server app2:80;
}
upstream api_upstream {
server api:80;
}
server {
listen 80 default_server;
server_name _;
# Applies to every location block below - as long as none of
# them sets its own proxy_set_header (see pitfall 2).
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 $connection_upgrade;
location / {
proxy_pass http://app_upstream;
}
location /api/ {
proxy_pass http://api_upstream/;
}
}
One detail about the trailing slash: proxy_pass http://api_upstream/; with a trailing slash replaces the matched prefix /api/ with /. The backend therefore sees /status instead of /api/status. Omit the slash and the path is passed through unchanged. This is the most common cause of 404s that only appear behind the proxy.
Starting and Verifying
docker compose up -d
docker compose exec proxy nginx -t
curl -s http://localhost:8080/
curl -s http://localhost:8080/
curl -s http://localhost:8080/api/
Repeated calls to / are spread across app1 and app2 – that is round-robin at work (not necessarily in strict alternation when several worker processes are running). /api/ always ends up at the api container.
Docker network "edge"
+--------+ +----------+ location / +-----------+
| Client | ---> | proxy | ---------------> | app1:80 |
| | | nginx | upstream | app2:80 |
+--------+ | :80 | app_upstream +-----------+
:8080 | |
| | location /api/ +-----------+
| | ---------------> | api:80 |
+----------+ upstream +-----------+
api_upstream
Three Typical Pitfalls
1. nginx resolves container names exactly once. If the upstream contains a bare server app1:80;, nginx determines the IP when the configuration is loaded. That has two unpleasant consequences. First: if app1 is not running yet when the proxy starts, nginx aborts with [emerg] host not found in upstream "app1" and ends up in a restart loop – the depends_on above guards against this. Second: if a backend receives a new IP when it is recreated, nginx keeps pointing at the old one and serves 502 until you reload. Since nginx 1.27.3 the clean solution is open source (previously commercial-only): the resolve parameter, together with a shared-memory zone and a resolver pointing at Docker's DNS.
upstream app_upstream {
zone app_upstream 64k;
resolver 127.0.0.11 valid=10s;
server app1:80 resolve;
server app2:80 resolve;
}
valid=10s overrides the TTL of the DNS response and thereby caps how long nginx holds on to a stale address.
2. Without proxy_set_header, the backend gets the wrong host. By default nginx applies proxy_set_header Host $proxy_host; – so the backend sees app_upstream as the Host header, not your domain. Applications using name-based virtual hosting will then serve the wrong site, and generated links point nowhere. Just as important: nginx never sets X-Forwarded-For and X-Forwarded-Proto on its own. Without them your application sees only the proxy as the client IP and treats every request as unencrypted HTTP – the classic cause of redirect loops behind TLS termination. And mind the inheritance rule: proxy_set_header is only inherited from the enclosing level if there is not a single proxy_set_header at the current level. One such line inside a location block therefore discards the entire set from the server block.
3. WebSockets need the upgrade headers explicitly. The map block above comes from the official WebSocket documentation and is not decoration: Connection defaults to close, which makes every upgrade attempt fail. The detour via map is necessary because Connection: upgrade may only be sent if the client actually asked for it. Two notes: since nginx 1.29.7 proxy_http_version defaults to 1.1 – on older versions you have to set the directive as well. And proxy_read_timeout defaults to 60 seconds, which cuts idle WebSocket connections after a minute unless the application sends pings.
Where to Go From Here
The stack above deliberately speaks plain HTTP on port 8080 only. For public operation you add a listen 443 ssl; plus ssl_certificate – automatic HTTPS, however, remains manual work with nginx: ACME support lives in a separate dynamic module (the nginx-module-acme package), which the current official images do ship (not the slim variants) but which must first be enabled via load_module and configured. If that is exactly your main problem, or if your containers change constantly, Caddy or Traefik are the better choice: both obtain certificates automatically and configure themselves from labels. Stay with nginx when you want a fixed, version-controlled configuration and maximum control over routing and headers.
For more depth, see ngx_http_upstream_module for load balancing, weights and health parameters, ngx_http_proxy_module for timeouts, buffering and headers, and the project's example configuration. Once the proxy is up, the next step is patch management – see the post on checking and hardening your nginx version.
Publication note: This article was scheduled for 23 July 2026. Because of a technical fault in our publishing automation, it did not go live until 11 August 2026. All information was re-checked for accuracy before publication.
Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).