Running one container is easy — publish its port and you’re done. Running three web apps on a single server gets ugly fast. Do you really want users hitting :8080, :8081, and :8082, and a separate TLS certificate wired into each one? That doesn’t scale and it’s a pain to maintain.
A reverse proxy fixes it. One Nginx container sits on ports 80 and 443, accepts every incoming request, and forwards it to the right app based on the hostname or URL path. The apps themselves never publish a port to the host — they only talk to the proxy over an internal Docker network.
This guide builds that setup with Docker Compose: two example apps behind one Nginx proxy, routed by hostname, with the proxy_pass config explained line by line. Then a short section on adding TLS so the whole thing serves HTTPS. If you’re new to Compose files, the Compose beginner guide covers the basics this builds on.
Why a reverse proxy
Publishing ports directly is fine for a single service. Add more and the problems pile up:
- Port sprawl. Every app needs its own host port, and your users have to remember them. Nobody wants to share a link ending in
:8083. - TLS everywhere. Without a proxy, each app terminates HTTPS itself, so you’re managing certificates in multiple containers.
- No central routing. You can’t send
app.example.comandblog.example.comto different containers on the same port 443 without something in front making that decision.
The proxy solves all three. It owns 80 and 443, terminates TLS once, and routes by hostname or path to whichever container should handle the request. The apps go back to being simple — they listen on their own port, internally, and never worry about the outside world.
flowchart LR U["Browser"] -->|app1.example.com| N["nginx proxy<br/>:80 / :443"] U2["Browser"] -->|app2.example.com| N N -->|proxy_pass http://app1:3000| A1["app1 container"] N -->|proxy_pass http://app2:8000| A2["app2 container"] subgraph net["shared Docker network: web"] N A1 A2 end
The Compose setup
Here’s the full stack: one proxy and two apps, all on a shared network called web. Notice that the apps don’t have a ports: section at all — only the proxy publishes to the host.
services:
proxy:
image: nginx:1.27
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app1
- app2
networks:
- web
app1:
image: my-app-one:latest # listens on 3000 internally
restart: unless-stopped
networks:
- web
app2:
image: my-app-two:latest # listens on 8000 internally
restart: unless-stopped
networks:
- web
networks:
web:
Two details carry the whole design. First, the apps have no published ports — they’re reachable only inside the web network, through the proxy. Second, all three services join that same web network, which is what lets Nginx find them by name.
How Nginx finds the containers
This is the part that makes Docker reverse proxies click. On a user-defined Docker network, services reach each other by service name. Docker runs an internal DNS resolver, so the hostname app1 resolves to the app1 container’s IP automatically. No IP addresses to look up, no links to wire.
That’s why the proxy config can say proxy_pass http://app1:3000 and it just works — app1 is the service name, 3000 is the port the app listens on inside its container. The port doesn’t need publishing because the proxy is talking to it over the shared network, not through the host.
Writing the proxy_pass config
Now the nginx.conf that the proxy mounts. This routes by hostname: requests for app1.example.com go to the first app, requests for app2.example.com go to the second.
server {
listen 80;
server_name app1.example.com;
location / {
proxy_pass http://app1:3000;
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;
}
}
server {
listen 80;
server_name app2.example.com;
location / {
proxy_pass http://app2:8000;
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;
}
}
Walking through what each piece does:
Key directives in the proxy config
| listen 80 | The port Nginx accepts requests on (published to the host). |
|---|---|
| server_name | Which hostname this block answers for — how Nginx routes by domain. |
| proxy_pass http://app1:3000 | Forward matched requests to the app1 container on its internal port. |
| proxy_set_header Host | Pass the original Host header so the app sees the real domain. |
| X-Real-IP / X-Forwarded-For | Tell the app the client's real IP, not the proxy's. |
| X-Forwarded-Proto | Tell the app whether the original request was http or https. |
Those proxy_set_header lines matter more than they look. Without them, the app sees every request as coming from the proxy’s IP over plain HTTP, which breaks logging, redirects, and anything that checks the client address. Forwarding the headers keeps the app aware of the real request.
After changing nginx.conf, reload the proxy without restarting the stack:
docker compose exec proxy nginx -s reload
Routing by path instead of hostname
Hostnames are the common pattern, but you can also route by URL path when you only have one domain. Use separate location blocks:
server {
listen 80;
server_name example.com;
location /app1/ {
proxy_pass http://app1:3000/;
}
location /app2/ {
proxy_pass http://app2:8000/;
}
}
The trailing slashes matter here. With a trailing slash on both the location and the proxy_pass target, Nginx strips the matched prefix before forwarding, so /app1/dashboard reaches the app as /dashboard. Drop the slash and the app receives the full /app1/dashboard, which usually isn’t what its router expects.
Adding TLS (HTTPS)
Terminating TLS at the proxy means you handle certificates in one place and the apps stay plain HTTP internally. The shape is a second server block on 443, with the certs mounted into the container, plus a redirect from 80 to 443.
server {
listen 80;
server_name app1.example.com;
return 301 https://$host$request_uri; # send everything to HTTPS
}
server {
listen 443 ssl;
server_name app1.example.com;
ssl_certificate /etc/nginx/certs/app1.crt;
ssl_certificate_key /etc/nginx/certs/app1.key;
location / {
proxy_pass http://app1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Mount the certificate folder into the proxy container so Nginx can read those files:
proxy:
image: nginx:1.27
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
networks:
- web
For real certificates you don’t want to renew by hand. The common approach is a companion container that obtains and renews certs from Let’s Encrypt automatically (ACME helpers and proxy images like nginx-proxy with an acme-companion, or Caddy as an Nginx alternative, all do this). That’s a topic of its own — the key idea to take away is that TLS terminates at the proxy, so the apps behind it never need certificates.
When it returns 502 Bad Gateway
A 502 from the proxy means Nginx is up but couldn’t get a good response from the upstream app. It’s the most common error in this setup, and the causes are short:
- The app container isn’t running or hasn’t finished starting. Check
docker compose ps. - The service name in
proxy_passdoesn’t match the actual service name in the compose file. - The port in
proxy_passis wrong — it must be the port the app listens on inside the container, not a host port. - The proxy and the app aren’t on the same network, so DNS can’t resolve the name.
Work through them in that order and the cause is usually obvious.
Reverse proxy sanity check
- Proxy and all apps share one Docker network
- Only the proxy publishes ports (80 and 443); apps publish nothing
- proxy_pass uses the service name and the app's internal port
- proxy_set_header lines forward Host and X-Forwarded-* to the app
- For HTTPS: 443 ssl block, certs mounted, port 80 redirects to 443
- docker compose ps shows every service running before you test
Wrapping up
A reverse proxy turns “a pile of containers on random ports” into “one front door.” Nginx owns 80 and 443, routes each request to the right container by hostname or path using Docker’s service-name DNS, and terminates TLS in a single place. The apps stay simple, unpublished, and reachable only through the proxy.
The two rules that make it work: put everything on one shared network, and only publish the proxy’s ports. Get those right and adding a third or fourth app is just another server block. If you hit a port conflict bringing the proxy up, the port-already-allocated fix covers it, and the Docker & Containers guides go deeper on networking and storage.