Production-ready Docker Compose stack with NGINX as a reverse proxy and subdomain-based routing to backend applications on an internal Docker network.
project-root/
├── docker-compose.yml
├── .env.example
├── nginx/
│ ├── conf.d/
│ │ └── aaa.conf # One file per subdomain/app
│ └── nginx.conf
└── apps/
└── aaa/
├── Dockerfile
├── package.json
└── src/
└── index.js
| Component | Role |
|---|---|
nginx |
Reverse proxy; exposes ports 80 and 443 |
aaa |
Sample Express app; listens on 3000 (internal only) |
proxy network |
Isolated bridge; services talk by service name |
Routing: aaa.localhost → NGINX → Docker service aaa:3000
- Docker and Docker Compose v2+
- Ability to edit
/etc/hosts(for local subdomain testing)
# From the project root
cp .env.example .env
docker compose up --build -dCheck status:
docker compose ps
docker compose logs -f nginxStop:
docker compose downAdd this line to your hosts file so the subdomain resolves to your machine:
macOS / Linux
sudo sh -c 'echo "127.0.0.1 aaa.localhost" >> /etc/hosts'Windows (run as Administrator)
127.0.0.1 aaa.localhost
Edit: C:\Windows\System32\drivers\etc\hosts
Verify:
ping -c 1 aaa.localhost# Root — JSON service info
curl -s http://aaa.localhost/ | jq
# Health endpoint (app + NGINX proxy path)
curl -s http://aaa.localhost/health | jq
# Verbose headers
curl -sv http://aaa.localhost/Expected root response:
{
"service": "aaa",
"status": "running"
}Expected health response:
{
"status": "ok"
}docker compose exec aaa wget -qO- http://127.0.0.1:3000/healthPort 443 is published on the host, but NGINX does not listen on 443 until you enable the SSL server block below. Until then, use HTTP on port 80 (see SSL with Let's Encrypt).
- Browser requests
http://aaa.localhost/→ host resolves to127.0.0.1. - Request hits NGINX on port 80.
- NGINX matches
server_name aaa.localhostinnginx/conf.d/aaa.conf. proxy_passsends traffic to upstreamaaa:3000(Docker DNS name, notlocalhost).- The
aaacontainer responds; NGINX returns the response to the client.
Follow this pattern for each new app (e.g. bbb at bbb.localhost):
apps/bbb/
├── Dockerfile
├── package.json
└── src/index.js
Listen on 0.0.0.0 and port 3000 (or set PORT via environment).
bbb:
build:
context: ./apps/bbb
expose:
- "3000"
networks:
- proxy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stoppedAdd bbb under nginx.depends_on if you want NGINX to wait for its health check.
Copy nginx/conf.d/aaa.conf → nginx/conf.d/bbb.conf and update:
server_name bbb.localhostupstream bbb_backend { server bbb:3000; }- All
proxy_passtargets tohttp://bbb_backend
Reload NGINX after changes:
docker compose exec nginx nginx -s reloadOr recreate the stack:
docker compose up -d --buildsudo sh -c 'echo "127.0.0.1 bbb.localhost" >> /etc/hosts'curl -s http://bbb.localhost/Tips for scaling
- Keep one
conf.d/<app>.confper subdomain — easy to review and diff. - Use upstream blocks and keepalive for each backend.
- Never
proxy_passtolocalhostinside NGINX; always use the Compose service name. - Do not publish app ports on the host unless you need direct debugging.
For production domains (not .localhost), common approaches:
-
Point real DNS (e.g.
aaa.example.com) to your server. -
Temporarily allow HTTP-01 on port 80, or use DNS-01 for wildcards.
-
Obtain certs:
certbot certonly --webroot -w /var/www/certbot -d aaa.example.com
-
Mount certificates into the NGINX container:
volumes: - ./certbot/conf:/etc/letsencrypt:ro
-
Uncomment and adapt the SSL
serverblock innginx/conf.d/aaa.conf. -
Uncomment the SSL
serverblock innginx/conf.d/aaa.confand mount your certificate paths. -
Add an HTTP → HTTPS redirect server block (example included as comments in
aaa.conf).
Let a dedicated edge proxy handle ACME automatically, with NGINX as an internal router — useful when you have many services.
Community images automate vhost discovery and certificate renewal from container labels.
Renewal: schedule certbot renew (cron or systemd timer) and reload NGINX after renewal:
docker compose exec nginx nginx -s reloadCopy .env.example to .env:
| Variable | Default | Description |
|---|---|---|
COMPOSE_PROJECT_NAME |
nginx-proxy |
Compose project name |
HTTP_PORT |
80 |
Host port for HTTP |
HTTPS_PORT |
443 |
Host port for HTTPS |
| Issue | Check |
|---|---|
| Connection refused | docker compose ps — are nginx and aaa healthy? |
| 502 Bad Gateway | docker compose logs aaa — is the app listening on 3000? |
| Wrong host / 404 | server_name in aaa.conf must match the Host header (aaa.localhost) |
| Name not resolving | /etc/hosts entry for aaa.localhost |
| Port 80 in use | Change HTTP_PORT in .env (e.g. 8080:80) |
MIT — use freely in your own projects.