Nginx reverse proxy for Docker containers: a practical guide
Configure Nginx as a reverse proxy routing multiple Docker containers by domain, with TLS, correct headers and isolated network.
When you have more than one HTTP container running on the same VPS, publishing ports on the host quickly turns into chaos: each service on a different port, firewall with 8 holes, replicated TLS certificate, scattered logs. The industry-standard solution is to put Nginx in front doing reverse proxy — one public port (443), multiple backends routed by hostname.
This tutorial shows how to run Nginx in a container, connected to backends via a dedicated Docker network, without exposing any backend port to the host. We’ll cover domain-based routing, correct proxy headers, automatic TLS via Let’s Encrypt and reload without downtime.
The target is a sysadmin with 2-3 containers already running (an API, an admin,
a public app) who wants to serve everything at api.example.com,
admin.example.com and example.com from the same IP. Estimated time:
30-40 minutes for the initial config, including certificate issuance.
Prerequisites
VPS with Ubuntu 22.04+ or Debian 12+, Docker Engine 24+ and Docker Compose
v2 installed. Root access or user in the docker group. DNS of the domains
already pointing to the VPS IP (A records with low TTL during setup). Ports
80 and 443 open on the firewall and no other process listening on them.
80 443 proxy-net /opt/nginx-proxy Before continuing, validate that nothing is using the ports:
sudo ss -tlnp | grep -E ':80|:443'
If Apache, another host Nginx or some already-bound container shows up, stop before continuing — two things listening on 443 don’t coexist.
Solution architecture
The design is: a user-defined Docker network called proxy-net where Nginx
and all backends live. The backends don’t publish any port to the host (no
-p in compose). Nginx is the only container with ports: 80:80 and
443:443. Inside the network, it resolves each backend by container name
via Docker’s internal DNS.
Advantages: an attacker on the public internet only sees 80/443. The backends stay isolated — even if the application has an admin endpoint without auth, it isn’t exposed. And adding a new service is a compose change plus a server block in Nginx, without touching the firewall.
Creating the dedicated network
Containers on different networks don’t see each other by name (the default bridge is the exception, but DNS there only works by IP, not by name). Our entire stack needs to be on the same user-defined network.
Create the proxy-net network:
docker network create proxy-netThis shows up in docker network ls as a bridge driver. You can check
it with docker network inspect proxy-net — it will be empty for now.
Create the reverse proxy working directory:
sudo mkdir -p /opt/nginx-proxy/{conf.d,certs,vhost,html}
sudo chown -R $USER:$USER /opt/nginx-proxy
cd /opt/nginx-proxyconf.d holds the server blocks per domain. certs is where issued
certificates will live. vhost allows per-host override. html serves
the Let’s Encrypt ACME challenge.
Bringing up Nginx in a container
We’ll use the official nginx:1.27-alpine image with config mounted via
bind mount. That way you edit files on the host and reload Nginx without
rebuilding the image.
Create /opt/nginx-proxy/docker-compose.yml:
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
- ./vhost:/etc/nginx/vhost.d:ro
- ./html:/usr/share/nginx/html:ro
networks:
- proxy-net
networks:
proxy-net:
external: trueexternal: true tells Compose the network already exists (we created it
in step 01) — without it, Compose creates a new one prefixed with the
project name and your Nginx won’t see the backends.
Create a default server block at conf.d/default.conf:
server {
listen 80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 444;
}
}return 444 is Nginx’s non-standard code that closes the connection
without a response — useful for rejecting requests with unknown Host
(bots scanning the IP directly).
Bring up Nginx:
docker compose up -d
docker compose logs nginxThe logs should show start worker processes without errors. If
bind() to 0.0.0.0:80 failed appears, something on the host is on the
port — go back and investigate before continuing.
Connecting backends to the proxy
The containers you already have running are probably on the default
network (bridge) or on one created by their Compose. They also need to
join proxy-net so Nginx can resolve them by name.
Add proxy-net to each backend’s compose. Example for an API:
services:
api:
image: my-api:latest
container_name: api-backend
restart: unless-stopped
networks:
- default
- proxy-net
# NOTE: no `ports:` — does not publish on the host
networks:
proxy-net:
external: trueRecreate the container: docker compose up -d --force-recreate. Keep
the default network for internal dependencies (database, redis) and add
proxy-net to be reachable by Nginx.
Validate that Nginx sees the backend by name:
docker exec nginx-proxy ping -c 2 api-backendA response with IP 172.x.x.x means the network DNS is working.
bad address means the backend isn’t on proxy-net — go back and
check docker network inspect proxy-net.
Configuring the reverse proxy by domain
Each domain becomes a separate file in conf.d/. This makes versioning
and debugging easier — you read only the relevant vhost.
Create conf.d/api.example.com.conf:
upstream api_backend {
server api-backend:3000;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/api.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location / {
proxy_pass http://api_backend;
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;
proxy_set_header Connection "";
proxy_read_timeout 60s;
}
}A named upstream with keepalive 32 maintains a pool of reusable
connections to the backend — it reduces latency by 5-15ms per request
compared to opening a new TCP every time. proxy_http_version 1.1 +
Connection "" is mandatory for keepalive to work.
For admin.example.com and example.com, copy the file changing
server_name, upstream and the certificate path. Don’t try to put
multiple server_name values in the same block — Let’s Encrypt issues
per name, and that complicates renewal.
Issuing TLS certificates
We’ll use certbot standalone via container, avoiding installing
Python on the host. Nginx serves the challenge at
/.well-known/acme-challenge/ that we configured in step 04.
Issue the certificate for each domain:
docker run --rm \
-v /opt/nginx-proxy/certs:/etc/letsencrypt \
-v /opt/nginx-proxy/html:/var/www/html \
certbot/certbot certonly \
--webroot -w /var/www/html \
-d api.example.com \
--email [email protected] \
--agree-tos --no-eff-emailThe fullchain.pem and privkey.pem files appear at
/opt/nginx-proxy/certs/live/api.example.com/. Adjust the
ssl_certificate in the server block to point to live/ instead of
the path I used in the example (which was simplified).
Validate the config and reload:
docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reloadnginx -t parses everything without applying — if a syntax error or a
missing cert file appears, it refuses the reload and keeps the previous
config running. Zero downtime guaranteed.
Add a daily cron running certbot renew --webroot -w /var/www/html
followed by docker exec nginx-proxy nginx -s reload. Let’s
Encrypt expires in 90 days — renewing every 60 gives you margin if some
DNS provider lags.
Verification
Test each domain externally:
curl -I https://api.example.com/healthz
You should see HTTP/2 200, server: nginx and the headers from your
backend. If 502 Bad Gateway comes up, Nginx reached but the upstream
went down — docker logs api-backend shows what happened. If
504 Gateway Timeout comes up, the backend is alive but didn’t respond
in 60s — adjust proxy_read_timeout or investigate slowness in the
application.
To confirm that the client’s real IP is reaching the backend, log
X-Forwarded-For in the application and hit a curl from another host.
If 172.x.x.x shows up instead of the client’s public IP, some
intermediate proxy is rewriting it — adjust the proxy trust setting in
the backend framework.
Next steps
From here, it’s worth enabling rate limiting with limit_req_zone for
sensitive endpoints (/login, /api/admin), adding Brotli as a
compression module to replace gzip, separating logs per domain with
a specific access_log per server block, and moving the config to a
versioned git repository.
If you’re putting this into serious production, a Hostini VPS already comes with Docker pre-installed, DDoS protection at the edge filtering attacks before they even reach Nginx, and automatic snapshots to revert any broken config in seconds — explore the plans at /vps.
Frequently asked questions
Why use Nginx in front of Docker instead of publishing ports directly?
Publishing ports (-p 8080:80) works for one container, but it doesn't scale. With Nginx in front you use a single port 80/443, route multiple domains to the same IP, terminate TLS in one place, centralize access logs and gain rate limiting, cache and WAF if you want. The containers stay on an isolated network without exposing ports on the host.
Do I need Nginx running on the host or can I run it in a container too?
Both work. Nginx in a container is cleaner (config versioned in compose, easy to bring up on another machine) but it requires Nginx to be on the same Docker network as the backends to resolve their names. Nginx on the host is easier to debug but you need to point to 127.0.0.1:published_port of each container. This guide uses Nginx in a container.
How does Nginx discover the backend container's IP?
Docker has internal DNS in every user-defined network. When you define a container named 'app' on the 'proxy-net' network, other containers on the same network resolve 'app' to the current IP via embedded DNS. That's why the Nginx upstream uses the container name, not a fixed IP — Docker rewrites it every time the container restarts.
Why do I need proxy_set_header Host and X-Forwarded-For?
Without Host, the backend receives Host: localhost (or internal name) and breaks any domain-based logic (cookies, redirects, multi-tenant). Without X-Forwarded-For, the backend sees Nginx's IP (172.x.x.x) on every request — logs, rate limiting and geoip become useless. These are the 2 mandatory headers in any reverse proxy.
How do I reload Nginx config without dropping active connections?
docker exec nginx-proxy nginx -s reload does a hot reload — the master process recompiles the config and old workers finish in-flight requests before dying. Zero downtime. Before reloading, always run docker exec nginx-proxy nginx -t to validate syntax — a broken config with reload keeps Nginx serving the previous one, but if a worker restarts from scratch it fails.
Does it work with WebSocket and HTTP/2?
WebSocket requires proxy_set_header Upgrade and Connection on the location — without it the handshake fails and the client falls back to long polling. HTTP/2 works out-of-the-box on listen 443 ssl http2, but only between client and Nginx; from Nginx to the backend it stays HTTP/1.1 (which is fine for 99% of cases). For gRPC, you need grpc_pass instead of proxy_pass.