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.

One Gunicorn config filegunicorn.conf.pyin the repoRead at startupauto-detectedEnv overridesworkers, bindSame configVPS, Docker, PaaSConfiguration becomes reviewed Python, with env vars for the few values that differ.
  1. Create gunicorn.conf.py in the project root (Gunicorn loads ./gunicorn.conf.py automatically) or pass -c path.
  2. Set bind, workers, threads, timeout, graceful_timeout, keepalive, max_requests, and max_requests_jitter.
  3. Log to stdout and stderr: accesslog = "-", errorlog = "-".
  4. Read environment-specific values from environment variables with defaults.
  5. Set forwarded_allow_ips only to your proxy addresses.
  6. 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.

settings worth setting explicitlyTypical valueWhyworkers2-4 per core, memory-boundparallel requeststhreads2-4 with gthreadI/O overlap per workertimeout30-60 skill hung requestsmax_requests1000 + jitter 100✓contain memory growthforwarded_allow_ipsproxy IPs only✓trust headers safelyExplicit values beat defaults that silently differ between hostsdjango-deployment.com
Measure per-worker memory with ps before raising worker counts.

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)"
a config file worth committingOne file used by every environmentsystemd, Docker, ProcfileEnv vars for values that differworkers, bind, log levelLogs to stdout and stderrwith request durationRecycling with jittermax_requests + jitterValidated with --check-configin CIConfiguration as code makes Gunicorn behaviour reviewabledjango-deployment.com
Print the effective config on the server when behaviour surprises you.

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"; threads does not apply.
  • timeout with 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

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.

Keep going