Django’s development server (runserver) isn’t built for production. The standard production setup is Gunicorn (a WSGI application server) running your Django app under systemd, with Nginx in front for HTTPS, static files and buffering. This guide deploys an existing project onto a fresh Ubuntu server.
Architecture
Browser ──HTTPS──▶ Nginx ──unix socket──▶ Gunicorn (workers) ──▶ Django ──▶ PostgreSQL
└── serves /static/ and /media/ directly
1. System packages
sudo apt update
sudo apt install -y python3-venv python3-dev build-essential libpq-dev \
postgresql nginx git
2. Database
sudo -u postgres psql
CREATE ROLE myapp WITH LOGIN PASSWORD 'a-long-random-password';
CREATE DATABASE myapp OWNER myapp;
\q
See PostgreSQL users and databases.
3. App user and code
sudo adduser --system --group --home /srv/myapp myapp
sudo -u myapp git clone https://github.com/you/myapp.git /srv/myapp/app
cd /srv/myapp/app
sudo -u myapp python3 -m venv /srv/myapp/venv
sudo -u myapp /srv/myapp/venv/bin/pip install -r requirements.txt gunicorn psycopg[binary]
4. Production settings
Keep secrets out of the code. Create /srv/myapp/.env (mode 600, owned by myapp):
DJANGO_SECRET_KEY=generate-a-long-random-string
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DATABASE_URL=postgres://myapp:[email protected]:5432/myapp
In settings.py, read them (with os.environ or a library like django-environ) and make sure production uses:
DEBUG = False
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
STATIC_ROOT = "/srv/myapp/static"
MEDIA_ROOT = "/srv/myapp/media"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_PROXY_SSL_HEADER tells Django that requests arriving from Nginx over HTTP were HTTPS for the visitor. Run Django’s own checklist:
sudo -u myapp bash -c 'set -a; . /srv/myapp/.env; /srv/myapp/venv/bin/python manage.py check --deploy'
5. Migrate and collect static files
cd /srv/myapp/app
sudo -u myapp bash -c 'set -a; . /srv/myapp/.env; set +a
/srv/myapp/venv/bin/python manage.py migrate
/srv/myapp/venv/bin/python manage.py collectstatic --noinput
/srv/myapp/venv/bin/python manage.py createsuperuser'
6. Gunicorn under systemd
Use socket activation so Nginx and Gunicorn communicate over a Unix socket.
/etc/systemd/system/gunicorn-myapp.socket:
[Unit]
Description=gunicorn socket for myapp
[Socket]
ListenStream=/run/gunicorn-myapp.sock
SocketUser=www-data
[Install]
WantedBy=sockets.target
/etc/systemd/system/gunicorn-myapp.service:
[Unit]
Description=gunicorn daemon for myapp
Requires=gunicorn-myapp.socket
After=network.target
[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/srv/myapp/.env
ExecStart=/srv/myapp/venv/bin/gunicorn \
--workers 3 \
--timeout 60 \
--access-logfile - \
myproject.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
Replace myproject with the package containing wsgi.py. A common starting point for workers is 2 × CPU cores + 1, adjusted for available RAM.
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn-myapp.socket
curl --unix-socket /run/gunicorn-myapp.sock http://localhost/ # triggers the service
systemctl status gunicorn-myapp
7. Nginx
/etc/nginx/sites-available/myapp:
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 20M;
location /static/ {
alias /srv/myapp/static/;
expires 30d;
}
location /media/ {
alias /srv/myapp/media/;
}
location / {
proxy_pass http://unix:/run/gunicorn-myapp.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Nginx (www-data) needs to traverse /srv/myapp and read the static and media folders:
sudo chmod 755 /srv/myapp
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
8. HTTPS
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
9. Deploying updates
/srv/myapp/deploy.sh:
#!/bin/bash
set -euo pipefail
cd /srv/myapp/app
git pull --ff-only
/srv/myapp/venv/bin/pip install -r requirements.txt
set -a; . /srv/myapp/.env; set +a
/srv/myapp/venv/bin/python manage.py migrate --noinput
/srv/myapp/venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart gunicorn-myapp
Run it as the myapp user (with a sudoers rule for the restart), or from CI — see GitHub Actions deploy to VPS.
Background tasks
Celery workers (with Redis as broker) run as another systemd service using the same venv and environment file. See Celery with Redis.
Troubleshooting
- 502 Bad Gateway — Gunicorn isn’t running or the socket path differs:
journalctl -u gunicorn-myapp -n 50. See Nginx 502. - 400 Bad Request — the domain isn’t in
ALLOWED_HOSTS. - CSRF verification failed on login —
CSRF_TRUSTED_ORIGINSmissing thehttps://origin. - Static files 404 —
collectstaticnot run, oraliaspath/trailing slash mismatch. - Redirect loop with
SECURE_SSL_REDIRECT— missingSECURE_PROXY_SSL_HEADERorX-Forwarded-Protoheader.
A Django app with PostgreSQL runs comfortably on 2 GB of RAM. Our Ubuntu VPS plans give you full control over every layer of this stack.