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 sudo privileges. This guide calls it your_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 --bind option: Gunicorn detects the socket that systemd passes to it.
  • --workers 3 is 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 LOGGING setting to send application errors to the journal or an error tracker.
  • Schedule regular PostgreSQL backups with pg_dump and a systemd timer.
  • Add a task queue such as Celery with Redis for work that should not run inside the request cycle.