The production stack at a glance
A typical Django deployment has five moving parts. Knowing what each one does makes debugging much faster when something goes wrong.
- PostgreSQL stores your data and should run with its own user and database per app
- Gunicorn runs your WSGI application with several worker processes on a local socket
- nginx terminates TLS, serves static and media files, and proxies everything else to Gunicorn
- Redis acts as the Celery broker and often as the cache backend
- Celery workers and Celery beat run background tasks and scheduled jobs
If you use Django Channels or an async framework next to Django, swap Gunicorn for Uvicorn workers or Daphne for those routes. Everything else in this guide stays the same.
Get the settings ready for production
Most first deploys fail on settings, not on servers. Split configuration so that production values come from environment variables, and never commit secrets. The minimum production checklist in settings.py looks like this.
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = ["app.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
STATIC_ROOT = BASE_DIR / "staticfiles"
DATABASES = {"default": dj_database_url.config(conn_max_age=60)}Run python manage.py check --deploy before your first release. It flags missing security headers, insecure cookies and a debug flag left on. The SECURE_PROXY_SSL_HEADER line matters because Gunicorn only sees plain HTTP from nginx; without it Django thinks every request is insecure and redirect loops follow.
Prepare PostgreSQL and Redis
Install PostgreSQL from the distribution or from the official PostgreSQL repository, then create a dedicated role and database. Give the role ownership of its database and nothing else.
sudo -u postgres psql -c "CREATE ROLE myapp LOGIN PASSWORD 'change-me';" sudo -u postgres psql -c "CREATE DATABASE myapp OWNER myapp;" sudo apt install -y redis-server
Keep PostgreSQL listening on localhost only unless another server needs it, and in that case allow just that IP in pg_hba.conf and the firewall. Redis should also bind to 127.0.0.1. A Redis instance open to the internet is one of the most common ways small servers get compromised.
Install Python and build a virtual environment per release
Use the Python version your project targets and create a fresh virtual environment for each release instead of upgrading one shared environment in place. That keeps a broken dependency install from breaking the running site. A release folder layout makes this natural.
REL=/home/deploy/myapp/releases/$(date +%Y%m%d-%H%M%S) git clone --depth 1 [email protected]:acme/myapp.git "$REL" ln -s /home/deploy/myapp/shared/.env "$REL/.env" python3.12 -m venv "$REL/.venv" "$REL/.venv/bin/pip" install -r "$REL/requirements.txt" cd "$REL" && .venv/bin/python manage.py collectstatic --noinput .venv/bin/python manage.py migrate --noinput ln -sfn "$REL" /home/deploy/myapp/current
Media files uploaded by users must live outside the release folders, in a shared directory or in object storage, otherwise every deploy hides the previous uploads. Link shared/media into each release the same way as the .env file.
Run Gunicorn under systemd
Gunicorn needs a process manager. systemd is already on the server and handles restarts, boot order and logs. Bind Gunicorn to a Unix socket so nothing but nginx can reach it.
[Unit] Description=myapp gunicorn After=network.target postgresql.service [Service] User=deploy WorkingDirectory=/home/deploy/myapp/current EnvironmentFile=/home/deploy/myapp/shared/.env ExecStart=/home/deploy/myapp/current/.venv/bin/gunicorn myapp.wsgi \ --workers 3 --bind unix:/run/myapp/gunicorn.sock --timeout 30 RuntimeDirectory=myapp ExecReload=/bin/kill -HUP $MAINPID Restart=always [Install] WantedBy=multi-user.target
A good starting point for workers is two times the CPU cores plus one, then adjust by measuring memory. Gunicorn reloads gracefully on HUP: it starts new workers with the new code and lets old workers finish their requests, which avoids dropping connections on deploy. With a symlinked current folder, Gunicorn keeps the path it resolved at start, so restart the service after switching releases to load code from the new folder.
Configure nginx for static files and the proxy
nginx serves collected static files directly and proxies dynamic requests to the Gunicorn socket. Serving static files through Django in production is slow and wastes workers.
server {
server_name app.example.com;
client_max_body_size 20m;
location /static/ { alias /home/deploy/myapp/current/staticfiles/; expires 30d; }
location /media/ { alias /home/deploy/myapp/shared/media/; }
location / {
proxy_pass http://unix:/run/myapp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Add a Let's Encrypt certificate with certbot and its nginx plugin. If you use WhiteNoise instead, you can skip the static location, but nginx still serves media better than Python does.
Run Celery workers and Celery beat
Celery needs at least one worker process and, if you use periodic tasks, exactly one beat process. Run each as its own systemd unit and restart both on every deploy, otherwise workers keep executing old task code against a new database schema.
ExecStart=/home/deploy/myapp/current/.venv/bin/celery -A myapp worker -l info --concurrency 2 ExecStart=/home/deploy/myapp/current/.venv/bin/celery -A myapp beat -l info
Restart workers with a graceful stop so tasks in progress finish, and set task time limits so a stuck task cannot hold a worker forever. Never run two beat processes, because every scheduled task would then run twice.
Handle migrations without downtime
Migrations are the riskiest part when you deploy django to production, because the old code keeps running while the new schema is applied. The safe rule is to make every migration compatible with both the old and the new code.
- Add columns as nullable or with a database default, then backfill, then add the constraint in a later release
- Rename a column in three steps across releases: add the new one, write to both, then drop the old one
- Create indexes on large PostgreSQL tables with AddIndexConcurrently and an atomic = False migration
- Take a backup before any migration that drops or rewrites data
Run migrate in the new release folder before the symlink switch. If the migration fails, the switch never happens and the old release keeps serving traffic.
Logs, backups and monitoring
Gunicorn, Celery and nginx all write logs that you will need the first time a request returns a 502. Send application errors to a tracker, keep journald logs for the services, and rotate nginx logs. Back up PostgreSQL with pg_dump on a schedule to storage outside the server, and test a restore at least once. Add an uptime check on a health URL that touches the database, so you know about an outage before your users do.
Common errors on the first deploy
A handful of errors show up on almost every first Django deploy. Knowing them saves an evening of guessing.
- A 400 Bad Request on every page usually means the domain is missing from ALLOWED_HOSTS
- A 403 CSRF failure on login forms after switching to HTTPS means CSRF_TRUSTED_ORIGINS lacks the https origin
- Pages without styles mean collectstatic did not run or the nginx alias path is wrong (note the trailing slash)
- A 502 Bad Gateway means Gunicorn is not running or nginx cannot read the socket, so check journalctl -u myapp and the socket permissions
- Endless redirects between http and https mean SECURE_PROXY_SSL_HEADER is not set or nginx does not send X-Forwarded-Proto
- Tasks that never run mean the Celery worker is connected to a different Redis database than the app
Check these in order before you dig deeper. Most of them are configuration, not code.
How Deployment deploys Django for you
Deployment sets up this exact stack on a server you own. It provisions the VPS with a firewall, SSH hardening and security updates, installs the Python version your project needs, creates the PostgreSQL database and user, and runs Redis bound to localhost. Every push to your branch builds a new release folder with a fresh virtual environment, runs collectstatic and migrate from your editable deploy script, checks the health URL and only then switches traffic, with one click rollback to earlier releases. Gunicorn, Celery workers and beat run as managed daemons that restart on each deploy, cron jobs keep their logs, and scheduled database backups go to your own S3-compatible storage. Read more on the django hosting page, see how we deploy from git, or paste your repository into the demo on the home page to get a deploy plan for it.