Write a gunicorn.conf.py for Django
Configure Gunicorn for Django with a gunicorn.conf.py file: bind, workers and threads, timeouts, max_requests with jitter, logging to stdout, forwarded headers, preload trade-offs, and server hooks for database connections.
Problem statement
Gunicorn command lines grow long: --workers 5 --threads 2 --timeout 60 --max-requests 1000 --access-logfile - ..., repeated in systemd units, Dockerfiles, Procfiles, and CI. Values drift between environments, comments are impossible, and options that need Python (computing workers from CPU count, hooks) cannot be expressed. A gunicorn.conf.py puts the configuration in one reviewed Python file that every environment uses.
- Create
gunicorn.conf.pyin the project root (Gunicorn loads./gunicorn.conf.pyautomatically) or pass-c path. - Set
bind,workers,threads,timeout,graceful_timeout,keepalive,max_requests, andmax_requests_jitter. - Log to stdout and stderr:
accesslog = "-",errorlog = "-". - Read environment-specific values from environment variables with defaults.
- Set
forwarded_allow_ipsonly to your proxy addresses. - Add hooks only where needed, such as closing database connections after fork when using
preload_app.
Step-by-step solution
1. A production-ready file
# gunicorn.conf.py
import multiprocessing
import os
# Socket
bind = os.environ.get("GUNICORN_BIND", "unix:/run/gunicorn/gunicorn.sock")
backlog = 2048
# Workers
workers = int(os.environ.get("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
worker_class = os.environ.get("GUNICORN_WORKER_CLASS", "gthread")
threads = int(os.environ.get("GUNICORN_THREADS", 2))
# Timeouts
timeout = int(os.environ.get("GUNICORN_TIMEOUT", 30))
graceful_timeout = 30
keepalive = 5
# Recycling
max_requests = 1000
max_requests_jitter = 100
# Logging
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("GUNICORN_LOG_LEVEL", "info")
access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(L)ss "%(a)s"'
# Proxy
forwarded_allow_ips = os.environ.get("GUNICORN_FORWARDED_ALLOW_IPS", "127.0.0.1")
# Process naming
proc_name = "myapp"
%(L)s logs request duration in seconds, which is invaluable for finding slow endpoints.
2. Workers and threads
The 2 × CPU + 1 rule is a starting point for sync workers. Memory usually limits you first on small servers: each worker is a full Django process (often 100-250 MB). With gthread, each worker handles threads requests concurrently, which helps I/O-bound views with fewer processes. In containers, cpu_count() may report the host's CPUs rather than the container limit, so set GUNICORN_WORKERS explicitly there.
3. Use it everywhere
# systemd
ExecStart=/srv/app/.venv/bin/gunicorn -c /srv/app/gunicorn.conf.py config.wsgi:application
CMD ["gunicorn", "-c", "gunicorn.conf.py", "config.wsgi:application"]
# Procfile
web: gunicorn -c gunicorn.conf.py config.wsgi:application
On PaaS, override GUNICORN_BIND to 0.0.0.0:$PORT through the environment, or rely on Gunicorn reading PORT when bind is not set.
4. Preloading and hooks
preload_app = True imports Django in the master before forking, saving memory through copy-on-write and making startup errors fail fast. But code that opens connections at import time (database, Redis, gRPC) would then share sockets across workers. Close them after fork:
preload_app = True
def post_fork(server, worker):
from django.db import connections
for conn in connections.all():
conn.close()
With preloading, HUP reloads do not pick up new code; use a full restart or the USR2 binary upgrade for deploys.
5. Validate the file
gunicorn -c gunicorn.conf.py --check-config config.wsgi:application
gunicorn -c gunicorn.conf.py --print-config config.wsgi:application | grep -E "^(workers|threads|timeout|bind)"
Verification check: --print-config shows the expected values on each host, systemctl restart gunicorn starts cleanly, the journal shows access lines with durations, and ps lists the configured number of workers.
Explanation
Gunicorn reads settings from several sources in order: built-in defaults, the config file, the GUNICORN_CMD_ARGS environment variable, and command-line options, with later sources winning. A gunicorn.conf.py is ordinary Python executed at startup, so it can compute values and define server hooks such as post_fork and worker_exit. Keeping it in the repository gives one source of truth, while environment variables handle the small set of values that legitimately differ between machines.
Edge cases / notes
- Uvicorn workers for ASGI use
worker_class = "uvicorn_worker.UvicornWorker";threadsdoes not apply. timeoutwith gthread applies to the whole worker heartbeat; a single slow request can still trigger it.- Secrets do not belong in the config file; Django reads them from the environment.
- Docker health checks should target the bind address configured here.
Next steps
- Size workers with How to Optimize Gunicorn Workers for Django.
- Run it under systemd with Set Up systemd for Gunicorn and Django.
FAQ
Where should gunicorn.conf.py live?
In the project root, committed to Git. Gunicorn loads ./gunicorn.conf.py automatically, or pass -c.
Can gunicorn.conf.py read environment variables?
Yes. It is Python, so use os.environ.get with defaults.
Should I enable preload_app?
It saves memory and fails fast, but needs post-fork connection handling and full restarts on deploy.
How do I see the effective Gunicorn configuration?
Run Gunicorn with --print-config.