This episode moves the expense tracker app from runserver to the standard production pair: Gunicorn runs the Python application, Nginx sits in front as a reverse proxy. The result: the app runs stably as a system service, restarts itself on failure, and serves correctly through a domain name. Following episode 2, this episode uses the production configuration split by environment variable, running for real.
Why both?
runserver is for development only: a single process, no concurrent load handling, no self-recovery. Gunicorn is a WSGI server that runs the Python application and distributes requests across multiple workers. Nginx handles the jobs Gunicorn does poorly: holding slow connections, serving static files, and attaching TLS certificates.
1. Install and try Gunicorn
Install Gunicorn and try running the expense tracker app:
pip install gunicorngunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3
The starting rule is (2 × CPU count) + 1, but a small server with one or two CPUs only needs three workers. Write the configuration into gunicorn.conf.py so every run is identical:
bind = "127.0.0.1:8000"workers = 3timeout = 30accesslog = "/var/log/gunicorn/access.log"errorlog = "/var/log/gunicorn/error.log"
timeout kills slow requests, such as report generation, instead of letting them hang forever. Verify with curl http://127.0.0.1:8000/ before configuring Nginx.
2. Run Gunicorn with systemd
A real server needs Gunicorn to start on boot and restart itself on crash. Create a systemd service running under a non-root user:
[Unit]Description=Expense tracker DjangoAfter=network.target[Service]User=deployWorkingDirectory=/srv/expenseEnvironmentFile=/srv/expense/.envExecStart=/srv/expense/venv/bin/gunicorn config.wsgi:applicationRestart=on-failure[Install]WantedBy=multi-user.target
Enable it with systemctl enable --now expense, then check with systemctl status expense. The .env file must belong to deploy and have restricted read permissions, because it holds SECRET_KEY and the database password from episode 2. Gunicorn listens on localhost only.
3. Nginx as a reverse proxy
Configure Nginx to forward requests from the domain into Gunicorn with the correct original headers:
server { listen 80; server_name chitieu.example.com; location / { proxy_pass http://127.0.0.1:8000; 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; client_max_body_size 10m; } location /static/ { alias /srv/expense/staticfiles/; }}
Those two headers tell Django the real IP and whether the original request used HTTPS. Enable the matching production settings:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")ALLOWED_HOSTS = ["chitieu.example.com"]
Only trust this header when Nginx is the single layer receiving requests from the Internet. After editing the configuration, run nginx -t then systemctl reload nginx.
4. End-to-end checks
Check from the inside out: Gunicorn (systemctl status expense), the application (curl -I http://127.0.0.1:8000/), then through Nginx (curl -I https://chitieu.example.com/). Read the Gunicorn log on a 502 error, the Nginx log when a request never reaches Gunicorn. Reboot once to be sure every service comes up on its own.
Apply it to your project
For the expense tracker app on a small VPS, three workers, a 30-second timeout, and Nginx serving static files as above are enough for the early stage. As users grow, scale workers with CPU first — episode 19 will cover when to scale out. Record the domain, the Gunicorn port, and the log paths in a runbook for fast incident handling.
Related posts
- Django Production Operations #1: Series Introduction + Production-Readiness Checklist
- Django Production Operations #2: Splitting Dev/Staging/Prod Settings + Environment Variables and Secrets
- Django: Fix N+1 Queries with select_related in 5 Minutes
Frequently asked questions
How many workers are enough for a small server?
Start with (2 × CPU count) + 1 and monitor CPU usage and response time.
Why still need Nginx when Gunicorn already runs?
Gunicorn is good at running Python code but poor at holding slow connections, serving static files, and managing TLS. Nginx takes over those jobs so workers can focus on application logic.
Where to start fixing a 502 Bad Gateway?
Check Gunicorn first: is the service running, is port 8000 listening, what does the error log say? If Gunicorn is healthy but 502 persists, check proxy_pass and run nginx -t.
Who serves static files?
Nginx serves them directly through the location /static/ block, much faster than Gunicorn reading from disk on every request. How static and media files are organized will be covered in episode 5.
Sources
- Gunicorn documentation: docs.gunicorn.org
- Nginx documentation, ngx_http_proxy_module: nginx.org
- Django documentation, deployment guide: docs.djangoproject.com
