Gunicorn troubleshooting
Gunicorn fails to start — import error
The WSGI application cannot be loaded. Check the module path
(myproject.wsgi:application), virtualenv in
ExecStart, and WorkingDirectory. Reproduce:
cd /opt/myapp && venv/bin/python -c "from myproject.wsgi import application".
Read journalctl -u gunicorn -n 50 for the traceback.
Also activate the same virtualenv and environment variables used by Gunicorn. Imports often fail because PYTHONPATH, DJANGO_SETTINGS_MODULE, or secrets/configuration are missing.
If import succeeds but the first HTTP request fails, see
App imports fine but crashes on first request below.
App imports fine but crashes on first request
Happens constantly — Gunicorn starts, the import check passes, but the first real request returns 500 or the worker dies. Import only loads modules; the first request triggers code paths that connect to dependencies.
# Import succeeds — no DB/Redis hit yet
cd /opt/myapp && venv/bin/python -c "from myapp.wsgi import application"
# First request fails
curl -v http://127.0.0.1:8000/Common causes on first request:
- Database unavailable — wrong host/credentials, firewall, or Postgres/MySQL not running; connection opens in a view or middleware
- Redis unavailable — cache/session broker unreachable; often lazy-connects on first cache or session access
- Missing secrets — API keys or env vars present in your shell
but not in the systemd unit or
gunicorn.conf.pyraw_env - Migration issues — schema out of date; first query hits a missing table or column
journalctl -u gunicorn -n 50 --no-pager
curl -v http://127.0.0.1:8000/ 2>&1 | tail -20
Read the traceback in journalctl or Gunicorn error logs — it
usually names the failing dependency. Reproduce with the same env Gunicorn
uses (systemctl show gunicorn -p Environment), not your interactive
shell. See Environment variables missing below if secrets
differ between shell and service.
502 Bad Gateway from nginx
Gunicorn is not running or nginx cannot reach it. Verify
systemctl status gunicorn, socket/TCP bind address matches
nginx proxy_pass, and socket permissions allow the nginx user
to connect (chmod 660, correct group).
Check: ls -ld /run /run/gunicorn* and ls -l /run/gunicorn.sock
Worker timeout / WORKER TIMEOUT in logs
A request exceeded timeout (default 30s). The worker was killed
and restarted. Fix slow views/queries, or increase timeout in
gunicorn.conf.py. Chronic timeouts point to app or database
performance issues, not just Gunicorn config. Long-polling or blocking I/O on
sync workers can exhaust all workers — see
Worker class mismatch below.
Workers repeatedly crashing (segfault / OOM)
Check dmesg or journalctl -kfor OOM killer. Reduce workers count or
fix memory leaks. Enable max_requests to recycle workers. Note that max_requests mitigates memory leaks; it doesn't fix them.
Native C extensions in the app can cause segfaults — check error logs for
signal 11.
Address already in use
Another process holds the bind port or stale Gunicorn master is still running.
Find it: ss -tlnp | grep 8000. Kill orphaned masters:
kill $(cat /run/gunicorn.pid)), or systemctl stop gunicorn before resorting to pkill -f gunicorn then restart the service cleanly.
Permission denied on Unix socket
nginx cannot connect to the socket. Ensure the socket directory exists,
Gunicorn creates the socket with a group nginx belongs to, or use TCP
on 127.0.0.1 instead. Check ls -la /run/gunicorn.sock.
On RHEL-family systems, correct Unix permissions may still fail due to SELinux labeling.
Check: ausearch -m avc -ts recent or journalctl -t setroubleshoot.
Environment variables missing
Gunicorn does not load your shell profile. Set environment variables in systemd or Gunicorn config; avoid relying on shell startup files:
# gunicorn.conf.py
raw_env = ["DJANGO_SETTINGS_MODULE=myproject.settings", "DEBUG=False"]
# Or in systemd unit:
Environment="DJANGO_SETTINGS_MODULE=myproject.settings"Silent failures — no access logs
Enable logging to stdout for systemd/journal capture:
accesslog = "-"
errorlog = "-"
loglevel = "info"
capture_output = TrueWorker class mismatch
Very common in production — the app needs concurrent or long-lived connections,
but Gunicorn runs with default sync workers. A typical trap:
gunicorn app:wsgi
# implicit worker_class = "sync" — wrong for:- WebSockets
- long polling
- streaming responses
Symptoms: apparent hangs (site stops responding under load), poor concurrency (one slow client blocks a whole worker), unexpected WORKER TIMEOUT kills — often misdiagnosed as nginx or database issues.
The default sync worker handles one request at a time per worker
process — fine for typical synchronous WSGI (Django, Flask) with short
request/response cycles. Pick a worker class that matches the workload:
sync(default) — one request per worker; scale withworkerscount; best for CPU-bound or fast sync viewsgthread— threads inside each worker;threads = N; good for I/O-bound sync apps without monkey-patchinggevent— greenlet-based concurrency; requiresgeventpackage and often monkey-patching; watch C extensions and DB drivers for compatibilityuvicorn.workers.UvicornWorker— for ASGI apps (FastAPI, Starlette, Django ASGI); not for plain WSGI — use Uvicorn/Hypercorn directly or this worker class withuvicorninstalled
# Default trap — sync implied
gunicorn app:wsgi
# gunicorn.conf.py — threaded I/O-bound WSGI
worker_class = "gthread"
workers = 2
threads = 8
# ASGI (FastAPI, WebSockets, etc.)
worker_class = "uvicorn.workers.UvicornWorker"
# Check what is running
ps aux | grep gunicorn
WebSockets through Gunicorn usually need an ASGI stack (Uvicorn worker or a
dedicated ASGI server), not default sync WSGI. If the app feels fine under
light load but stalls under concurrent slow clients, verify
worker_class before raising workers or
timeout alone.
--preload and application state
--preload (or preload = True in
gunicorn.conf.py) loads the WSGI application in the
master process before workers are forked. Workers inherit the
same memory via copy-on-write, which can reduce RAM use — but anything
stateful initialized at import time crosses the fork boundary.
Common footgun: database connection pools, Redis clients, open file handles, or locks created during module import are duplicated into every worker from a single master-side instance. After fork, those resources are invalid or shared — causing intermittent stale DB connections, "connection already closed", broken file descriptors, and errors that disappear after a worker restart.
# gunicorn.conf.py — memory savings, fork pitfalls
preload = True
# CLI equivalent
gunicorn --preload myapp.wsgi:application
# Check if enabled
grep -i preload /etc/gunicorn*.py systemd/gunicorn.service
Fix: disable preload for apps that open connections at import, or reconnect in a
post_fork hook (Django: ensure connections close on fork; lazy-init
clients inside request handlers). If failures are random across workers and clear
after kill -HUP, suspect preload + shared state before chasing app
logic bugs.
Debugging workflow
1. Service status and logs
systemctl status gunicorn
journalctl -u gunicorn -n 100 --no-pager2. Test app import and direct HTTP
cd /opt/myapp && venv/bin/python -c "from myapp.wsgi import application"
curl -v http://127.0.0.1:8000/.If a health endpoint exists:
curl -v http://127.0.0.1:8000/health3. Confirm proxy matches bind
ss -tlnp | grep gunicorn
grep proxy_pass /etc/nginx/sites-enabled/*Practice scenarios
Hands-on Gunicorn scenarios on live Linux VMs: gunicorn