Nginx Reverse Proxy & Multi-Container Docker Compose Stack
Put the whole stack behind one entry point, with Nginx serving static files and Gunicorn handling the rest.
- Time
- 39 min
- Level
- Beginner
- Objectives
- 4 objectives
- Cost
- Free
Where this fits in the platform
Already built
This lab adds
- The whole application running locally, behind one entry point
Which lets you
The scenario#
Gunicorn is serving CSS. It is a Python process reading files off disk and writing them to a socket, and it is doing that instead of handling requests — so the site is slow under load for no good reason.
There is also no TLS, no static caching, and the application crashes on boot roughly one time in three because PostgreSQL is not accepting connections yet.
Hands-on environment
Run this lab in a real terminal, free and in your browser. The environment is temporary and yours alone — break it as much as you like.
Open the terminalOpens in Killercoda, in a new tab — keep this page open for the steps.
Run it on your own machine
Run this lab on your own machine. One command starts the environment, with everything the lab needs already installed:
You will need:
- docker
git clone https://github.com/EgyKode/EgyKode-lab.git
cd EgyKode-lab
./egykode start
./egykode shellYou need Docker and Git installed. Everything else runs inside the environment. The first start downloads it and takes a few minutes; later starts are seconds.
Not sure what you already have? Run: npm run doctor — it checks and changes nothing.
Anything you tick here is your own record. EgyKode cannot see inside that terminal, so the success criteria stay self-assessed even when the environment checks your work for you.
The Nginx configuration
Step 1 of 3
What you are building#
Browser
│ :80
v
┌──────────────────────── compose network ────────────────────────┐
│ nginx /static/ > served from a shared volume, on disk │
│ / > proxy_pass to gunicorn │
│ │ │
│ v │
│ web (gunicorn) ──> db (postgres) ──> named volume │
│ ──> cache (redis) │
└─────────────────────────────────────────────────────────────────┘Why not let Gunicorn serve static files? It can, and every worker that is
streaming a CSS file is a worker not handling a request. Nginx does that with
sendfile in the kernel, at a cost close to zero, and adds caching headers
while it is there. This is the division of labour every Python deployment ends
up with.
Build it#
What you are proving: You can serve static files directly, proxy the rest, and pass the real client IP through
Marking this settles success criteria 1 and 4.
# nginx/default.conf
upstream django {
server web:8000;
}
server {
listen 80;
server_name _;
client_max_body_size 20M;
# Served from disk. Never reaches Python.
location /static/ {
alias /app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location /media/ {
alias /app/media/;
expires 7d;
}
location / {
proxy_pass http://django;
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_connect_timeout 5s;
proxy_read_timeout 60s;
}
}The four proxy_set_header lines are not boilerplate. Without them the
application sees every request as coming from the Nginx container's IP, so
rate limiting, audit logs and geolocation all break in a way that looks
correct in testing — because in testing there is one client.
What you are proving: You can express start order and persistence in Compose, so the app waits for a ready database and its data survives a restart
Marking this settles success criteria 2 and 3.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 5
web:
build: .
environment:
DB_HOST: db
REDIS_HOST: cache
volumes:
- static:/app/static
depends_on:
db:
condition: service_healthy # ready, not merely started
cache:
condition: service_healthy
expose:
- "8000" # visible to nginx, not to the host
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- static:/app/static:ro # the same volume web writes to
depends_on:
- web
volumes:
pgdata:
static:Three details worth pointing at:
exposerather thanportsonweb. Nginx reaches it on the compose network; the host does not need to, and publishing it would let clients skip the proxy entirely.${DB_PASSWORD:?...}fails theupwith a clear message rather than silently starting PostgreSQL with an empty password.- The
staticvolume is mounted twice — read-write byweb, read-only bynginx. That is how Nginx serves files a Python process collected.
What you are proving: You can bring the stack up and check each claim against the running system rather than the file
This step settles no success criterion on its own.
export DB_PASSWORD=labonly
docker compose up -d --build
docker compose ps # db and cache show "healthy", not "running"Verify it worked#
# Static served by nginx, not Python
curl -sI http://localhost/static/css/site.css | grep -i 'server\|cache-control'
# Server: nginx/1.27.x Cache-Control: public, immutable
# Dynamic proxied through
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/
# The app sees the real client IP
docker compose logs web --tail 5 | grep -o 'X-Forwarded-For[^ ]*'
# Data survives a full stop
docker compose exec db psql -U app -c "CREATE TABLE t(id int); INSERT INTO t VALUES (1);"
docker compose down && docker compose up -d
docker compose exec db psql -U app -c "SELECT * FROM t;"That last sequence is the one people skip and the one that matters. down
destroys containers and keeps named volumes; down -v destroys the volumes
too, and it is one character away.
502 Bad Gateway from Nginx
Nginx started and the upstream did not answer. In order: is web running
(docker compose ps), is it listening on 0.0.0.0:8000 rather than
127.0.0.1 inside its container, and does the upstream name match the
service name exactly?
404 on static files, dynamic pages fine
The static volume is empty. Whatever collects static assets has not run, or
it ran in a container that mounted a different volume. docker compose exec nginx ls /app/static settles it.
host not found in upstream "web" and Nginx will not start
Compose resolves service names on its own network. This is a typo, or Nginx is
on a different network. Note that Nginx resolves upstreams at startup and
then caches — if web was not up yet, Nginx fails permanently rather than
retrying.
The app crashes on first boot, works on restart
depends_on without condition: service_healthy waits only for the container
to start. PostgreSQL accepts connections several seconds after that.
Everything is gone after a restart
docker compose down -v, or the volume was anonymous. Named volumes in the
top-level volumes: block persist.
Clean up#
docker compose down -v
docker volume prune -fCost of this lab: Free — everything runs locally.
Success criteria
0 of 4
The concept behind it
Next up
Lab 11 of 59 on the project path