DevOps Intermediate

Deploy Django with Gunicorn & Nginx on Linux

Step-by-step guide to deploying a Django application on a Linux server with Gunicorn, Nginx, and systemd. Production-ready setup.

KC Ramo · DjangoZen Team Mar 29, 2026 21 min read 628 views

Building a Django application is one thing; getting it running reliably on a real server is another, and the gap trips up many developers. The standard production setup pairs Gunicorn, an application server that runs your Django code, with Nginx, a web server that sits in front handling client connections and static files. Understanding why this architecture exists and how the pieces fit makes deploying Django far less mysterious. This tutorial walks through the production stack on a Linux server.

Why not the development server

Django's built-in development server is perfect for development and entirely unsuitable for production. It is single-threaded, not built for performance or security, and explicitly warned against for production use. Serving real users requires a proper application server that can handle concurrent requests efficiently and securely. Understanding this is the starting point: the command you use to run Django while developing is not how you run it in production, and reaching for a production-grade server is not optional but necessary. The development server's convenience comes with limitations that make it inappropriate the moment real users and real traffic are involved.

The production architecture

The standard production setup has a clear division of labor. Gunicorn is the application server: it runs your Django code, executing your views to produce responses, across multiple worker processes for concurrency. Nginx is the web server in front: it accepts connections from clients, serves static files directly, and passes dynamic requests back to Gunicorn. This two-layer design — Nginx handling what it does best at the front, Gunicorn running your Python application behind it — is the architecture most production Django runs on. Understanding the role of each piece is the key to the whole setup and to diagnosing issues when something does not work.

Gunicorn: the application server

Gunicorn runs your Django application, translating incoming requests into calls to your code and returning the responses. It runs multiple worker processes so it can handle several requests concurrently, which is essential for serving more than one user at a time. You point it at your Django application and configure the number of workers based on your server's resources. Gunicorn is mature, reliable, and the common choice for running Django in production. Understanding that Gunicorn is the process actually executing your Django code — the engine running your application behind the scenes — clarifies its central role in the stack and why its configuration affects your application's capacity.

A configuration file instead of long command lines

Gunicorn reads settings from a Python file, which is easier to review and version than a growing list of flags. Gunicorn automatically loads ./gunicorn.conf.py from the working directory, or you can pass -c /path/to/gunicorn.conf.py. The examples in this tutorial assume the code is checked out in /srv/app/src, a Python 3.12+ virtual environment lives in /srv/app/venv, and everything runs as an unprivileged system user named deploy:

# /srv/app/gunicorn.conf.py
import multiprocessing

wsgi_app = "myproject.wsgi:application"

# Leave "bind" unset when systemd socket activation passes the socket in.
# bind = "unix:/run/gunicorn.sock"

workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 30              # kill a worker that is silent for this many seconds
graceful_timeout = 30     # time given to finish in-flight requests on restart
keepalive = 2             # Nginx is the client, so short keep-alive is fine

max_requests = 1000       # recycle workers periodically...
max_requests_jitter = 100 # ...but not all at the same moment

accesslog = "-"           # stdout, collected by journald
errorlog = "-"            # stderr
loglevel = "info"

max_requests is a pragmatic safety net: if a library slowly leaks memory, each worker is replaced after a thousand requests, and the jitter spreads the restarts out so capacity never drops all at once. It is not a substitute for fixing a real leak, but it keeps a small leak from becoming an outage.

Configuring workers

Gunicorn's concurrency comes from worker processes, and how many you run affects how many requests your application can handle at once. A common guideline bases the number on your server's CPU cores, with the right figure depending on whether your workload is CPU-bound or spends time waiting on the database and external services. Too few workers limits throughput; too many wastes memory. Tuning the worker count to your server and workload is part of getting good performance from the stack. Understanding that workers are what give Gunicorn its concurrency, and that their number is a deliberate configuration choice rather than a default to ignore, helps you size your application appropriately.

Choosing a worker class

Worker classHow it handles concurrencyGood fitWatch out for
sync (default)One request per processTypical CRUD apps with fast viewsSlow external calls block the whole worker
gthreadSeveral threads per process (set threads)I/O-heavy views, memory-constrained serversCode and libraries must be thread-safe
geventMany green threads via monkey-patchingMany long-waiting requestsPatching surprises; needs compatible database drivers
ASGI server (e.g. Uvicorn)Event loop per processAsync views, WebSockets via ChannelsSync ORM calls in async views still need care

Setting threads to a value above 1 with the default worker class switches Gunicorn to gthread automatically. A common memory-saving configuration is workers = 3 with threads = 4, giving twelve concurrent request slots for the memory cost of three processes. Remember that each thread may hold its own database connection, so total connections become workers × threads, which matters when PostgreSQL's max_connections is limited.

Nginx: the web server

Nginx sits at the front of the stack, facing the internet, and handles the things it does far better than an application server: accepting and managing many client connections efficiently, serving static files like CSS, JavaScript, and images directly without involving Django, and forwarding dynamic requests to Gunicorn. It also commonly handles HTTPS termination. Putting Nginx in front offloads connection handling and static file serving from your application, letting Gunicorn focus on running your code. Understanding Nginx's role as the efficient front door that handles connections and static content, passing only dynamic work to your application, explains why this layered architecture performs and scales well.

A complete server block

On Debian and Ubuntu, site configurations live in /etc/nginx/sites-available/ and are enabled with a symlink in sites-enabled/. This is a working HTTP-only configuration; HTTPS is added in a later section:

# /etc/nginx/sites-available/myproject
upstream myproject_app {
    server unix:/run/gunicorn.sock fail_timeout=0;
}

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    client_max_body_size 10m;   # default is 1m; uploads above it get 413

    location /static/ {
        alias /srv/app/staticfiles/;
        expires 30d;
        access_log off;
    }

    location /media/ {
        alias /srv/app/media/;
    }

    location / {
        proxy_pass http://myproject_app;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }
}
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default   # optional: remove the welcome page
sudo nginx -t                              # always validate before reloading
sudo systemctl reload nginx

Get into the habit of running nginx -t before every reload. A reload with a broken configuration is refused and the old configuration keeps running, but a restart with a broken configuration leaves you with no web server at all.

How the reverse proxy works

The arrangement where Nginx receives requests and passes them to Gunicorn is called a reverse proxy. A client connects to Nginx, which decides whether to serve the request itself (for static files) or forward it to Gunicorn (for dynamic Django responses) and then relay Gunicorn's response back to the client. The client never talks to Gunicorn directly. This indirection is what lets Nginx handle the public-facing concerns while Gunicorn stays focused on the application. Understanding the reverse-proxy flow — request comes to Nginx, gets routed appropriately, response goes back — is the mental model that makes the whole setup and its configuration comprehensible.

What Django needs to know about the proxy

Because the client talks to Nginx, the connection Django sees always comes from Nginx over a local socket. Without extra information, Django would think every request was plain HTTP from the local machine. The headers set in the server block carry the original details forward, and two settings tell Django to trust them:

# settings.py (production)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

SECURE_PROXY_SSL_HEADER makes request.is_secure() return True for requests that reached Nginx over HTTPS, which in turn makes build_absolute_uri() produce https:// links and prevents redirect loops when SECURE_SSL_REDIRECT is on. Only set it when Nginx always overwrites the header, as the proxy_set_header X-Forwarded-Proto $scheme; line does; otherwise a client could send the header itself and claim a secure connection that never existed.

Serving static and media files

Django applications have static files — CSS, JavaScript, images — and often media files uploaded by users, and these should be served by Nginx directly rather than through Django, which is inefficient at it. In production you collect your static files into one location and configure Nginx to serve them from there, so requests for static content are handled fast by Nginx without touching your application. Media files are handled similarly. Getting this right is important both for performance and because static files often simply do not appear if the collection and Nginx configuration are not set up correctly, which is a common deployment frustration that understanding this step prevents.

The settings and the command

# settings.py (production)
from pathlib import Path

STATIC_URL = "static/"
STATIC_ROOT = Path("/srv/app/staticfiles")
MEDIA_URL = "media/"
MEDIA_ROOT = Path("/srv/app/media")

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
    },
}
sudo -u deploy /srv/app/venv/bin/python /srv/app/src/manage.py collectstatic --noinput

ManifestStaticFilesStorage writes each file with a content hash in its name (for example app.3f2a9c1d.css) and makes the {% static %} tag point at the hashed name. That is what makes the expires 30d line in the Nginx config safe: browsers can cache aggressively because a changed file gets a new URL. The trade-off is that a template referencing a file that does not exist raises an error during rendering in production, so run collectstatic in CI as well to catch missing assets before deploy.

Keeping Gunicorn running

Gunicorn needs to run continuously and restart if it crashes or the server reboots, which is the job of a process manager — on modern Linux, typically systemd. You define a service that starts Gunicorn, keeps it running, restarts it on failure, and starts it automatically when the server boots. Without this, Gunicorn would stop the moment you closed your terminal or the server restarted. Setting up Gunicorn as a managed service is what makes the deployment durable rather than something you have to babysit. Understanding that a process manager keeps your application server alive and recovering from failures is essential to a production setup that stays up reliably.

A socket unit and a service unit

The layout recommended in Gunicorn's own documentation uses two systemd units. The socket unit owns /run/gunicorn.sock; the service unit runs Gunicorn and receives the already-open socket. Because the socket exists independently of the process, Nginx connections queue during a restart instead of failing:

# /etc/systemd/system/gunicorn.socket
[Unit]
Description=gunicorn socket

[Socket]
ListenStream=/run/gunicorn.sock
SocketUser=www-data
SocketMode=0600

[Install]
WantedBy=sockets.target
# /etc/systemd/system/gunicorn.service
[Unit]
Description=gunicorn daemon for myproject
Requires=gunicorn.socket
After=network.target postgresql.service

[Service]
Type=notify
NotifyAccess=main
User=deploy
Group=deploy
RuntimeDirectory=gunicorn
WorkingDirectory=/srv/app/src
EnvironmentFile=/etc/myproject/env
ExecStart=/srv/app/venv/bin/gunicorn -c /srv/app/gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket
sudo systemctl start gunicorn.service      # or let the first request start it

systemctl status gunicorn.service          # is it running? last log lines
curl --unix-socket /run/gunicorn.sock -H "Host: example.com" http://localhost/

The socket is owned by www-data with mode 0600, so only Nginx can connect to it; the Gunicorn process, running as deploy, does not need to open the file because systemd hands it the listening socket directly. The curl check therefore needs to run as root or www-data. Type=notify lets Gunicorn tell systemd when it is actually ready, so dependent units and systemctl start wait for a real startup rather than just a launched process.

Adding HTTPS

Any production site should serve traffic over HTTPS, and Nginx is where this is typically handled. You obtain a certificate — modern tooling makes this free and largely automatic — and configure Nginx to use it, encrypting traffic and redirecting plain HTTP to HTTPS. Because Nginx is the front-facing layer, it is the natural place to terminate HTTPS, decrypting incoming requests before passing them to Gunicorn. Setting up HTTPS is a non-negotiable part of deploying a real site, protecting your users' data in transit, and the combination of Nginx and automated certificate tooling makes it a manageable step rather than the ordeal it once was.

Obtaining a certificate with Certbot

Let's Encrypt certificates, obtained with the Certbot client, are the common free option. Point your DNS records for example.com and www.example.com at the server first, make sure port 80 is reachable, then run:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

# confirm automatic renewal works; certificates last 90 days
sudo certbot renew --dry-run
systemctl list-timers | grep certbot

The Nginx plugin edits your server block to add listen 443 ssl, the certificate paths, and a redirect from port 80. Read the resulting file afterwards so you know what changed; it is still your configuration to maintain. The renewal timer (or cron job, depending on how Certbot was installed) reloads Nginx after renewing, and the dry run is the only way to find out ahead of time that renewal is blocked, for example by a firewall rule or a location block that intercepts the /.well-known/acme-challenge/ path.

A deployment checklist

Pulling it together, a production Django deployment involves configuring Django's settings for production (debug off, allowed hosts set, secret key secured), running it under Gunicorn with an appropriate number of workers, putting Nginx in front as a reverse proxy serving static files and forwarding dynamic requests, managing Gunicorn with a process manager so it stays running, and enabling HTTPS. Each piece addresses a specific concern, and together they form the reliable, performant, secure stack that production Django runs on. Working through these steps methodically — settings, Gunicorn, Nginx, process management, HTTPS — turns the daunting task of deployment into a clear sequence with a well-understood result.

Environment configuration

A production deployment needs its configuration — secret key, database credentials, debug setting, allowed hosts — supplied through the environment rather than hard-coded, both for security and to keep the same code working across environments. You load these from environment variables on the server, keeping secrets out of your code and version control. This separation of configuration from code is a foundational deployment practice. Understanding that production settings come from the server's environment, not from values committed in your settings file, is essential both to keeping credentials secure and to running the same application safely across development and production with environment-appropriate configuration in each.

Reading configuration in settings.py

Keep the settings file honest: required values should fail loudly when missing instead of silently falling back to a development default. A missing SECRET_KEY should stop the server from starting, not quietly use the insecure key from your laptop.

# settings.py
import os

def env_list(name, default=""):
    return [v.strip() for v in os.environ.get(name, default).split(",") if v.strip()]

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]            # KeyError if missing: good
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")

On the server, the values live in the file referenced by EnvironmentFile= in the systemd unit. Note that systemd environment files are not shell scripts: no export, and quoting rules are simpler than in Bash.

# /etc/myproject/env   (owner root:deploy, mode 640)
DJANGO_SETTINGS_MODULE=myproject.settings
DJANGO_SECRET_KEY=replace-with-output-of-get_random_secret_key
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
DATABASE_PASSWORD=replace-me
# generate a strong random key
/srv/app/venv/bin/python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

sudo install -o root -g deploy -m 640 /dev/null /etc/myproject/env

One practical wrinkle: management commands you run over SSH do not automatically see the variables systemd injects into the service. Either load the file into your shell for a single command (set -a; . /etc/myproject/env; set +a works for simple files without spaces or special characters) or wrap common commands in a small script that does so, so that migrate and createsuperuser run against the same configuration as the live application.

The database in production

Production Django almost always uses a proper database like PostgreSQL rather than the development default, running either on the same server or a separate one. You configure Django to connect to it, ensure it is set up and secured, and run your migrations against it to create the schema. The database is a critical part of the deployment that needs its own setup and care, including backups. Understanding that deploying involves provisioning and connecting a production-grade database — not relying on the development database — is part of the complete picture, since your application's data lives there and its proper configuration is as important as the application server itself.

Logging and monitoring

In production you cannot watch the console as you do in development, so configuring logging to capture what your application is doing — errors, important events — and having a way to review it is important for knowing your application's health and diagnosing problems. Beyond logs, monitoring that alerts you when something goes wrong lets you respond before users are badly affected. Setting up logging and basic monitoring is part of operating a deployed application responsibly. Understanding that a production deployment needs visibility into its behavior — so that errors and issues are captured and noticed rather than silently happening — is what lets you keep a deployed application running well over time.

Deploying updates

Deployment is not a one-time event; you will update the application as you develop it, and doing so smoothly matters. Updating typically involves getting the new code onto the server, installing any new dependencies, running new migrations, collecting static files, and restarting the application server so it runs the new code. Having a reliable, repeatable process for this — ideally automated — makes updates routine rather than risky. Understanding that deployment includes an ongoing update process, not just the initial setup, prepares you for the reality of maintaining a live application, where you regularly ship changes and want each update to go out reliably without disrupting your users.

A repeatable deploy script

Writing the steps down as a script is the first step towards automation, and it removes the "did I remember to collect static files?" class of incident. The script stops at the first failing command, so a failed migration never leads to a restart onto half-deployed code:

#!/usr/bin/env bash
# /srv/app/deploy.sh  -- run as the deploy user
set -euo pipefail

cd /srv/app/src
set -a; . /etc/myproject/env; set +a

git pull --ff-only
/srv/app/venv/bin/pip install -r requirements.txt
/srv/app/venv/bin/python manage.py check --deploy --fail-level WARNING
/srv/app/venv/bin/python manage.py migrate --noinput
/srv/app/venv/bin/python manage.py collectstatic --noinput

sudo systemctl reload gunicorn
curl -fsS -o /dev/null https://example.com/ && echo "deploy OK"

Allowing the deploy user to run exactly that one reload command, and nothing else, is a narrow sudoers rule (edit it with visudo): deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload gunicorn. Because --fail-level WARNING turns deploy-check warnings into failures, list any warning you have deliberately accepted (such as security.W008 when Nginx performs the HTTPS redirect) in SILENCED_SYSTEM_CHECKS, so the script only stops for new problems. The final curl is a minimal smoke test; the -f flag makes it fail on HTTP errors, so the script reports a failure instead of "deploy OK".

Troubleshooting common failures

Most deployment problems produce one of a few symptoms, and each points at a specific layer. Start from the symptom, then check the log for that layer.

SymptomLikely causeWhere to look / fix
502 Bad GatewayGunicorn not running, crashed on import, or Nginx cannot open the socketsystemctl status gunicorn, journalctl -u gunicorn, Nginx error.log for Permission denied
502 after about 30 secondsWorker killed by Gunicorn's timeoutWORKER TIMEOUT in the journal; move slow work to a background task
504 Gateway TimeoutNginx's proxy_read_timeout (60 s default) expired firstSame as above; align the two timeouts, keep both short
400 Bad Request on every pageHost not in ALLOWED_HOSTSJournal shows Invalid HTTP_HOST header
403 CSRF verification failed on formsMissing HTTPS origin in CSRF_TRUSTED_ORIGINS or missing X-Forwarded-ProtoAdd the origin with its scheme; check proxy headers
Endless redirect loopSECURE_SSL_REDIRECT without SECURE_PROXY_SSL_HEADERSet the proxy header, or let Nginx do the redirect
Pages without CSScollectstatic not run, wrong alias, or permissionsNginx error.log, namei -l on a file path
413 Request Entity Too LargeUpload larger than client_max_body_sizeRaise it in the server block and reload Nginx
Old code still running after deployNo reload, or preload_app with a reload instead of a restartWith preload_app = True the master holds the old code, so use systemctl restart; check process start times with ps

Summary

Deploying Django to production means moving beyond the development server to a proper stack, and the standard one pairs Gunicorn with Nginx on a Linux server. Gunicorn is the application server that actually runs your Django code across multiple worker processes for concurrency, while Nginx is the web server in front, acting as a reverse proxy that efficiently handles client connections, serves static and media files directly, and forwards dynamic requests to Gunicorn. A process manager like systemd keeps Gunicorn running and restarts it on failure or reboot, and Nginx is also where you terminate HTTPS using a certificate to encrypt all traffic. Configure Django's settings for production, collect and serve static files through Nginx, and work through the deployment as a clear sequence. Understanding the role of each layer — what Gunicorn runs, what Nginx fronts, what keeps it alive, and how HTTPS fits — turns deployment from a mysterious hurdle into a comprehensible, repeatable process that gets your application running reliably for real users.