Django's built-in runserver is meant for development only. In production, a WSGI server such as Gunicorn runs your Python code, and Nginx sits in front of it to terminate HTTPS, serve static files and buffer slow clients. In this tutorial you will deploy a Django project on Ubuntu 24.04 with PostgreSQL as the database, Gunicorn managed by systemd socket activation, Nginx as the reverse proxy and a free Let's Encrypt certificate.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
- A non-root user with
sudoprivileges. This guide calls ityour_user. - A domain name (
your_domain) with an A record pointing to your server's public IP address. It is needed for the HTTPS step. - UFW enabled with SSH allowed (
sudo ufw allow OpenSSH && sudo ufw enable).
Throughout the guide the project is called myproject and lives in /srv/myproject. Replace these names with your own where they appear.
Step 1 - Installing the system packages
Ubuntu 24.04 ships Python 3.12, which is supported by current Django releases. Install the venv module, PostgreSQL, Nginx and Git:
sudo apt update
sudo apt install python3-venv python3-dev postgresql nginx git
Check that PostgreSQL and Nginx are running:
systemctl is-active postgresql nginx
active
active
Step 2 - Creating the PostgreSQL database
Open a PostgreSQL shell as the postgres superuser:
sudo -u postgres psql
Create a role for the application and a database owned by that role. Replace your_strong_password with a long random password made of letters and digits only, so it is safe to use in the environment file later:
CREATE ROLE myproject_user WITH LOGIN PASSWORD 'your_strong_password';
CREATE DATABASE myproject OWNER myproject_user;
\q
Making the role the owner of the database gives Django the privileges it needs to create tables in the public schema on PostgreSQL 16.
Verify that the new role can log in over TCP, which is how Django will connect:
psql -h localhost -U myproject_user -d myproject -c 'SELECT current_user;'
Enter the password when asked. You should see:
current_user
----------------
myproject_user
(1 row)
Step 3 - Setting up the project and virtual environment
Create the project directory and give your user ownership of it:
sudo mkdir -p /srv/myproject
sudo chown your_user:your_user /srv/myproject
cd /srv/myproject
If you already have a project in a Git repository, clone it into this directory instead of creating a new one:
git clone https://github.com/your_user/myproject.git .
Create a virtual environment and activate it. Everything the application needs is installed inside it, isolated from the system Python:
python3 -m venv /srv/myproject/venv
source /srv/myproject/venv/bin/activate
Install Django, Gunicorn and the PostgreSQL driver. If your project has a requirements.txt, run pip install -r requirements.txt instead and make sure it contains gunicorn and psycopg:
pip install --upgrade pip
pip install django gunicorn "psycopg[binary]"
If you are starting from scratch, create a new project in the current directory:
django-admin startproject myproject .
Confirm the installed versions:
python -m django --version
gunicorn --version
6.0.1
gunicorn (version 23.0.0)
Your version numbers may be higher.
Step 4 - Configuring Django for production
Secrets and host names should not be hardcoded in settings.py. Instead you will keep them in an environment file that systemd loads for Gunicorn.
Generate a secret key:
python3 -c 'import secrets; print(secrets.token_urlsafe(50))'
Create the environment file:
nano /srv/myproject/.env
Add the following, using the key you just generated and the database password from Step 2:
DJANGO_SECRET_KEY=paste_the_generated_key_here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=your_domain,www.your_domain
DB_NAME=myproject
DB_USER=myproject_user
DB_PASSWORD=your_strong_password
Restrict the file so only your user can read it:
chmod 600 /srv/myproject/.env
Now open the settings module:
nano /srv/myproject/myproject/settings.py
Add import os next to the existing imports at the top of the file, then replace the SECRET_KEY, DEBUG, ALLOWED_HOSTS and DATABASES settings with these versions:
import os
from pathlib import Path
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
"USER": os.environ["DB_USER"],
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": "localhost",
"PORT": "5432",
"CONN_MAX_AGE": 60,
}
}
CONN_MAX_AGE keeps database connections open for 60 seconds instead of reconnecting on every request.
At the end of the file, below the existing STATIC_URL, tell Django where to collect static files and where to store user uploads. Nginx will serve both directories directly:
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
Load the environment file into your current shell so manage.py can read it:
set -a
source /srv/myproject/.env
set +a
Create the database tables, an administrator account and the static files directory:
python manage.py migrate
python manage.py createsuperuser
python manage.py collectstatic --noinput
The last command should end with a line like this:
127 static files copied to '/srv/myproject/staticfiles'.
Create the media directory so uploads have somewhere to go:
mkdir -p /srv/myproject/media
Before involving systemd, check that Gunicorn can load the application:
gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application
From a second SSH session, request the admin login page with the correct Host header:
curl -I -H "Host: your_domain" http://127.0.0.1:8000/admin/login/
HTTP/1.1 200 OK
Server: gunicorn
Content-Type: text/html; charset=utf-8
Stop Gunicorn with CTRL+C and leave the virtual environment:
deactivate
Step 5 - Running Gunicorn with systemd
Using a systemd socket unit means systemd creates the Unix socket at boot and starts Gunicorn on the first request. Create the socket unit:
sudo nano /etc/systemd/system/gunicorn.socket
[Unit]
Description=gunicorn socket
[Socket]
ListenStream=/run/gunicorn.sock
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660
[Install]
WantedBy=sockets.target
Only the www-data user, which Nginx runs as, can connect to the socket. Next, create the service unit:
sudo nano /etc/systemd/system/gunicorn.service
[Unit]
Description=gunicorn daemon for myproject
Requires=gunicorn.socket
After=network.target
[Service]
Type=notify
NotifyAccess=main
User=your_user
Group=www-data
WorkingDirectory=/srv/myproject
EnvironmentFile=/srv/myproject/.env
ExecStart=/srv/myproject/venv/bin/gunicorn --workers 3 --access-logfile - myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target
A few notes on this unit:
- There is no
--bindoption: Gunicorn detects the socket that systemd passes to it. --workers 3is a good starting point for a 1 vCPU server. The usual rule is(2 x CPU cores) + 1.--access-logfile -sends access logs to the journal.
Start the socket and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket
Send a request through the socket. This also starts the Gunicorn service:
sudo curl -I -H "Host: your_domain" --unix-socket /run/gunicorn.sock http://localhost/admin/login/
HTTP/1.1 200 OK
Server: gunicorn
Content-Type: text/html; charset=utf-8
Check that the service is now running:
sudo systemctl status gunicorn
● gunicorn.service - gunicorn daemon for myproject
Loaded: loaded (/etc/systemd/system/gunicorn.service; disabled; preset: enabled)
Active: active (running) since Thu 2026-09-24 10:12:03 UTC; 5s ago
TriggeredBy: ● gunicorn.socket
The service shows as disabled because the socket starts it on demand, which is expected. If it failed, read the error with sudo journalctl -u gunicorn -n 50 --no-pager.
Step 6 - Configuring Nginx as a reverse proxy
Create a server block for the site:
sudo nano /etc/nginx/sites-available/myproject
server {
listen 80;
listen [::]:80;
server_name your_domain www.your_domain;
client_max_body_size 20M;
location /static/ {
alias /srv/myproject/staticfiles/;
expires 30d;
access_log off;
}
location /media/ {
alias /srv/myproject/media/;
}
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn.sock;
}
}
The proxy_params file shipped with Ubuntu's Nginx sets the Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers. client_max_body_size sets the largest upload Nginx will accept.
Enable the site, disable the default one and test the configuration:
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload Nginx and open HTTP and HTTPS in the firewall:
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
Visit http://your_domain/admin/ in a browser. You should see the Django admin login page with its styles loaded, which confirms that Nginx serves the static files.
Step 7 - Enabling HTTPS with Let's Encrypt
Install Certbot and its Nginx plugin:
sudo apt install certbot python3-certbot-nginx
Request a certificate. Certbot edits the server block to add the certificate and a redirect from HTTP to HTTPS:
sudo certbot --nginx -d your_domain -d www.your_domain
The Ubuntu package installs a systemd timer that renews certificates automatically. Test renewal with:
sudo certbot renew --dry-run
Now tell Django that it runs behind an HTTPS proxy, so it builds https:// URLs and marks cookies as secure. Add these lines to the end of settings.py:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600
Start with a short HSTS value and raise it (for example to 31536000) once you have confirmed the whole site works over HTTPS.
Run Django's deployment checklist to catch remaining issues:
cd /srv/myproject
source venv/bin/activate
set -a; source .env; set +a
python manage.py check --deploy
Review each warning it reports; some, like SECURE_SSL_REDIRECT, are already handled by the Nginx redirect. Then reload Gunicorn so it picks up the new settings:
sudo systemctl reload gunicorn
Log in at https://your_domain/admin/ to confirm everything works end to end.
Step 8 - Deploying updates
When you push new code, update the server with these commands:
cd /srv/myproject
git pull
source venv/bin/activate
pip install -r requirements.txt
set -a; source .env; set +a
python manage.py migrate
python manage.py collectstatic --noinput
sudo systemctl reload gunicorn
systemctl reload sends HUP to Gunicorn, which starts new workers with the new code and shuts down the old ones gracefully. If you changed the systemd unit or the .env file, use sudo systemctl restart gunicorn instead.
Troubleshooting
502 Bad Gateway: Nginx cannot reach Gunicorn. Check sudo systemctl status gunicorn.socket gunicorn and sudo journalctl -u gunicorn -n 50 --no-pager. A Python import error or a missing variable in .env (KeyError: 'DJANGO_SECRET_KEY') is the most common cause.
400 Bad Request: the requested host is not in DJANGO_ALLOWED_HOSTS. Add it to .env and restart Gunicorn.
Admin page without styles: static files are not being served. Make sure you ran collectstatic and that the alias path in Nginx ends with a slash and matches STATIC_ROOT. Check /var/log/nginx/error.log for permission denied errors.
CSRF verification failed after enabling HTTPS: Django does not know the request was HTTPS. Confirm SECURE_PROXY_SSL_HEADER is set and that the Nginx location includes proxy_params. If the site is reached through another domain or port, add it to CSRF_TRUSTED_ORIGINS, for example CSRF_TRUSTED_ORIGINS = ["https://your_domain"].
Conclusion
Your Django project now runs under Gunicorn with systemd socket activation, stores data in PostgreSQL and is served by Nginx over HTTPS, with static and media files handled directly by Nginx. Updates are a pull, migrate and reload away.
As next steps you can:
- Configure Django's
LOGGINGsetting to send application errors to the journal or an error tracker. - Schedule regular PostgreSQL backups with
pg_dumpand a systemd timer. - Add a task queue such as Celery with Redis for work that should not run inside the request cycle.
