Docker Compose — Nginx Reverse Proxy Project
Build an Nginx reverse proxy that load-balances traffic across multiple backend application instances — a fundamental production pattern.
Architecture
┌──────────────────────────────────────────────────────┐
│ Docker Network: proxy-net │
│ │
│ ┌─────────────┐ │
│ Client ──→ │ Nginx │ │
│ :80 │ Reverse │ │
│ │ Proxy │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ App 1 │ │ App 2 │ │ App 3 │ │
│ │ :5000 │ │ :5000 │ │ :5000 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────┘
Project Setup
mkdir nginx-proxy && cd nginx-proxy
mkdir app nginx
Step 1 — Create the Backend App
Create app/server.py:
from http.server import HTTPServer, BaseHTTPRequestHandler
import os, json
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
hostname = os.environ.get('HOSTNAME', 'unknown')
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
'message': 'Hello from backend',
'hostname': hostname,
'path': self.path
}).encode())
HTTPServer(('0.0.0.0', 5000), Handler).serve_forever()
Create app/Dockerfile:
FROM python:3.12-alpine
WORKDIR /app
COPY server.py .
EXPOSE 5000
CMD ["python", "server.py"]
Step 2 — Create Nginx Configuration
Create nginx/nginx.conf:
upstream backend {
server app:5000;
}
server {
listen 80;
location / {
proxy_pass http://backend;
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;
}
location /health {
access_log off;
return 200 'healthy';
add_header Content-Type text/plain;
}
}
Step 3 — The Compose File
Create compose.yml:
services:
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- proxy-net
app:
build:
context: ./app
deploy:
replicas: 3
expose:
- "5000"
networks:
- proxy-net
networks:
proxy-net:
driver: bridge
Key detail: The
appservice usesexpose(notports) because only Nginx needs to reach it — no host-level port mapping required.
Step 4 — Launch the Stack
docker compose up -d --build
Expected output:
[+] Running 5/5
✔ Network nginx-proxy_proxy-net Created
✔ Container nginx-proxy-app-1 Started
✔ Container nginx-proxy-app-2 Started
✔ Container nginx-proxy-app-3 Started
✔ Container nginx-proxy-nginx-1 Started
Step 5 — Test Load Balancing
Send multiple requests and observe different hostnames in the response:
for i in 1 2 3 4 5 6; do
curl -s http://localhost/ | python3 -m json.tool
echo "---"
done
You should see different hostname values as Nginx distributes requests across the three app replicas:
{
"message": "Hello from backend",
"hostname": "a1b2c3d4e5f6",
"path": "/"
}
---
{
"message": "Hello from backend",
"hostname": "f6e5d4c3b2a1",
"path": "/"
}
---
Step 6 — Scale Up and Down
Scale the backend to 5 instances:
docker compose up -d --scale app=5
docker compose ps
NAME STATUS PORTS
nginx-proxy-app-1 Up 5000/tcp
nginx-proxy-app-2 Up 5000/tcp
nginx-proxy-app-3 Up 5000/tcp
nginx-proxy-app-4 Up 5000/tcp
nginx-proxy-app-5 Up 5000/tcp
nginx-proxy-nginx-1 Up 0.0.0.0:80->80/tcp
Scale back down:
docker compose up -d --scale app=2
Enhancements to Try
- Add SSL termination with a self-signed certificate
- Add static file serving from Nginx directly
- Add rate limiting in the Nginx configuration
- Add a monitoring dashboard with Nginx status module
Quick Reference
| Item | Detail |
|---|---|
| Nginx image | nginx:alpine |
| Proxy port | 80:80 |
| Backend port | 5000 (exposed, not published) |
| Replicas | 3 (configurable via --scale) |
| Load balancing | Round-robin (default Nginx upstream) |
| Config mount | ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro |