
A reverse proxy sits between the internet and your apps, and Nginx remains the most practical choice for most Docker setups. It handles TLS, routes multiple domains to different containers, and adds almost no overhead. I have run this exact shape on a cheap VPS for years: one Nginx container in front, everything else behind it, zero drama.
Skip the alternatives unless you have a specific reason. Caddy wins if you want automatic HTTPS with zero config. Traefik wins if you run dozens of services with Docker labels. For a handful of services on one server, plain Nginx is the tool you will still be using in five years.
This guide shows you the full setup: a Compose stack with Nginx in front, a config that points each domain at the right container, free HTTPS with Certbot, and the three settings people always forget.
Every service you run in Docker wants its own port. Port 3000 for the app, 8080 for the dashboard, 9000 for the admin panel. Exposing all of them directly is messy: visitors would need to remember ports, and each open port is a door you must guard.
A reverse proxy closes that gap. It listens on the standard ports (80 and 443) and forwards each request to the right container based on the domain name or path. The client never sees your internal ports. Your containers never talk to the internet directly.
The flow looks like this: browser requests app.example.com → Nginx on port 443 terminates TLS → Nginx forwards the request to http://app:3000 inside the Docker network → the app responds → Nginx hands the response back. One entry point, one TLS certificate story, one set of logs. Nginx’s own glossary explains the concept with a bit more formality if you want the textbook version.
There is a second benefit people undervalue: headers. A good proxy config sets X-Forwarded-For and X-Forwarded-Proto so your app logs the real client IP and knows the original request was HTTPS. Without those four header lines, your app logs show every visitor as 127.0.0.1, and rate limiting becomes useless.
Three other tools compete for this job. Each has a real strength, so here is the honest comparison.
| Tool | Config Style | Automatic HTTPS | Best For |
|---|---|---|---|
| Nginx | Text config files | No (pair with Certbot) | Most Docker servers, 1-20 services |
| Caddy | Simple text config | Yes, built in | Single-server setups, minimal config fuss |
| Traefik | Docker labels | Yes, built in | Many services, frequent redeploys |
| HAProxy | Text config files | No | High-traffic load balancing |
Caddy deserves a serious look if certificates are your main pain. This site already covered setting up Caddy with automatic HTTPS, and it remains the fastest path from zero to HTTPS. The tradeoff is a smaller module ecosystem and less control over edge cases.
Traefik shines when services come and go constantly: you add two Docker labels, and it picks up new routes without a reload. That magic costs complexity in debugging. When Traefik routes something wrong, you debug label resolution instead of reading a config file.
I keep coming back to Nginx for one reason: predictability. The config file says exactly what happens. Nothing auto-discovers anything. When traffic grows, the same config pattern scales to a bigger box or a load balancer without relearning anything.
Put Nginx in its own container on the same Docker network as your apps. Do not install Nginx on the host: keeping it in Compose means your entire proxy layer is one docker compose up away from a rebuild. The isolated-containers approach covered here before applies: each service stays in its own container with clear network boundaries.
Here is a minimal Compose file to start from:
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
- app
restart: unless-stopped
app:
image: your-app:latest
expose:
- "3000"
restart: unless-stopped
Two details matter here. First, the app uses expose, not ports. The app port stays inside the Docker network; only Nginx faces the internet. Second, the config directory mounts read-only (:ro). A compromised Nginx container should not be able to rewrite its own config.
Pin the image tag. nginx:latest moves under you and breaks things on rebuild night. Alpine variants use less disk and start faster; the standard config syntax works identically.
Create one config file per domain inside ./nginx/conf.d. Here is a working starting point for app.example.com:
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://app: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;
}
}
The critical line is proxy_pass http://app:3000. app is the Docker Compose service name, which Docker’s built-in DNS resolves to the container. Docker’s networking docs cover how service-name DNS works on user-defined networks; in Compose you get this for free.
Add a second domain by adding a second file with a different server_name and proxy_pass target. Reload with docker compose exec nginx nginx -s reload. No container restart needed. I have seen people rebuild the whole stack for a routing change; the reload takes under a second.
Watch the trailing slash in proxy_pass: proxy_pass http://app:3000; and proxy_pass http://app:3000/; behave differently when combined with a location prefix. The trailing slash version strips the matched prefix. Pick one deliberately, and test. The official Nginx reverse proxy guide walks through the exact cases.
Free certificates from Let’s Encrypt pair cleanly with this setup. Install Certbot on the host, then request a cert for each domain you proxy:
sudo certbot certonly -w /var/www/letsencrypt \
-d app.example.com -d dashboard.example.com
Point the webroot at a directory you share with the Nginx container, and add a challenge location to your config:
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
Then add the TLS block to your server config:
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/certs/app.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/app.example.com/privkey.pem;
location / {
proxy_pass http://app: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;
}
}
Mount the host’s /etc/letsencrypt into the container at /etc/nginx/certs (read-only). Renewals run through the host’s cron timer; Nginx picks up renewed certs after a reload. If you would rather not hand certificates to containers yourself, the official Certbot instructions for Nginx cover every distribution and web server variant.
One habit that saves real money: set a calendar reminder to check renewal 30 days out, even with automation. Certbot’s timer usually works. When it does not, it fails silently, and your first warning is an expired-certificate page from an angry user.
Two defaults trip up nearly every first-time setup. Nginx speaks HTTP/1.0 to upstreams by default, which breaks WebSocket upgrades. If your app uses live chat, collaborative editing, or realtime dashboards, add these lines inside the location block:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
The second trap is client_max_body_size, which defaults to 1MB. File uploads larger than that die with a 413 error and no useful message. If your app accepts uploads, set it to something sane:
client_max_body_size 50M;
Place it in the http context for a global default, or per-server for specific apps. I wasted an afternoon on this once: a 4MB photo upload failing silently while everything else worked. The setting took 30 seconds to fix.
Three lightweight habits keep the whole thing healthy long-term. First, cap log growth. Nginx access logs on a busy site grow fast, so rotate them with logrotate or cap the Docker JSON log driver. This site covered how systemd journals eat VPS disk space; Nginx logs cause the same slow disaster through a different door.
Second, run a syntax check before every reload: docker compose exec nginx nginx -t. A typo in one conf.d file can take down every domain on the server. The test takes half a second and has saved my production more than once.
Third, watch the backend, not just Nginx. A reverse proxy that answers 200 while all upstreams are dead is a convincing liar. Point a simple uptime check at one real page behind the proxy. The WordPress speed guide here notes the same principle: measure what the visitor actually receives, not what one layer reports.
You now have a complete, production-worthy shape: one Nginx container, one Compose network, domain-per-config files, free TLS, and the settings that prevent the two classic failures. Nothing in this setup is clever, and that is the point. Explicit routing is debuggable at 3 AM; magic routing is not.
Where to go next depends on your bottleneck. Hitting traffic limits? Read up on Nginx caching and rate limiting. Fighting with large container images instead? Multi-stage Docker builds cut image size dramatically. And if this whole config-file approach feels like too much ceremony for one or two services, the Caddy path with automatic HTTPS is genuinely excellent; this 2026 Nginx reverse proxy walkthrough also makes a good second reference once you start tuning.
Start with one service behind the proxy this week. Add the second domain next week. By the third, you will wonder why you ever exposed raw container ports at all.