← Blog
Engineering

How to deploy a Django app on a VPS

Yura Oak
Yura OakAugust 21, 2026

To deploy a Django app on a VPS you need six pieces: a hardened server, Postgres, gunicorn under systemd, nginx in front of it, a TLS certificate, and a release routine that runs migrations and collects static files. This guide is the whole thing on Ubuntu 26.04 LTS, with the commands that work and the two mistakes that cost everyone an afternoon. It takes about 40 minutes the first time. At the end there is an honest section on what you now own — and how the same deploy looks on a platform, if it turns out you wanted the app live rather than a server to run.

Written 20 August 2026, against Ubuntu 26.04 LTS "Resolute Raccoon".


What you need before you start

  • A VPS with 1 vCPU and 2 GB of memory, running Ubuntu 26.04 LTS. That is roughly $6 to $12 a month at Hetzner, DigitalOcean, or Vultr. 1 GB works until pip install meets a package that compiles.
  • A domain with an A record pointing at the server's IP.
  • A Django project in a Git repository, with a requirements.txt.
  • An SSH key. Not a password.

Step 1 — Lock the box down first

Do this before anything else, because an open SSH port with password auth gets found in minutes.

ssh root@your-server-ip

adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

apt update && apt upgrade -y
apt install -y unattended-upgrades

Ports rather than the 'Nginx Full' profile, because that profile ships with the nginx-common package and does not exist until Step 2 installs it.

Now harden SSH. Do not edit /etc/ssh/sshd_config directly: cloud images from Hetzner, DigitalOcean and Vultr ship /etc/ssh/sshd_config.d/50-cloud-init.conf with PasswordAuthentication yes, and sshd takes the first value it finds. Write a drop-in that sorts after it:

printf 'PasswordAuthentication no\nPermitRootLogin no\n' | sudo tee /etc/ssh/sshd_config.d/99-hardening.conf
sudo systemctl restart ssh
``` Log out and back in as `deploy` before you close the first session — if you locked yourself out, you want to find out while the old session is still open.

## Step 2 — Install the system packages

```bash
sudo apt install -y python3-venv python3-dev build-essential \
                    libpq-dev postgresql postgresql-contrib nginx curl

libpq-dev and build-essential are what a source build of psycopg needs. The wheel installed in Step 4, psycopg[binary], bundles libpq and compiles nothing — but anything in your own requirements.txt that builds against Postgres will want them, and leaving them out produces a pg_config executable not found error that reads like a Python problem and is not.

Step 3 — Create the database

sudo -u postgres psql
CREATE DATABASE myproject;
CREATE USER myproject WITH PASSWORD 'a-long-random-password';
ALTER ROLE myproject SET client_encoding TO 'utf8';
ALTER ROLE myproject SET default_transaction_isolation TO 'read committed';
ALTER ROLE myproject SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE myproject TO myproject;
\c myproject
GRANT ALL ON SCHEMA public TO myproject;

That last line is the one everybody misses. PostgreSQL 15 removed the CREATE permission every role used to inherit on the public schema, and a database-level GRANT ALL PRIVILEGES has never covered it, so migrate fails with permission denied for schema public even though the user "owns" the database.

Step 4 — Get the code and its dependencies

sudo mkdir -p /srv/myproject && sudo chown deploy:deploy /srv/myproject
git clone https://github.com/you/myproject.git /srv/myproject
cd /srv/myproject

python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install gunicorn "psycopg[binary]" dj-database-url

Step 5 — Configuration through the environment

Never commit secrets. Put them in a file only root and the service user can read:

sudo tee /etc/myproject.env >/dev/null <<'EOF'
DJANGO_SETTINGS_MODULE=myproject.settings
DJANGO_SECRET_KEY=generate-a-long-random-one
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DATABASE_URL=postgres://myproject:a-long-random-password@localhost:5432/myproject
EOF
sudo chown root:deploy /etc/myproject.env
sudo chmod 640 /etc/myproject.env

And read them in settings.py:

import os
import dj_database_url

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]

DATABASES = {"default": dj_database_url.config(conn_max_age=600)}

STATIC_URL = "/static/"
STATIC_ROOT = "/srv/myproject/staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = "/srv/myproject/media"

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

CSRF_TRUSTED_ORIGINS and SECURE_PROXY_SSL_HEADER are the pair that stops "CSRF verification failed" once nginx terminates TLS in front of you.

Step 6 — Migrate and collect static files

set -a && . /etc/myproject.env && set +a
.venv/bin/python manage.py migrate
.venv/bin/python manage.py collectstatic --noinput
.venv/bin/python manage.py createsuperuser

Step 7 — Run gunicorn under systemd

Two units: a socket systemd owns, and a service that binds to it. The socket means systemd can hand nginx a listening endpoint even while gunicorn restarts.

/etc/systemd/system/gunicorn.socket:

[Unit]
Description=gunicorn socket

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

[Install]
WantedBy=sockets.target

/etc/systemd/system/gunicorn.service:

[Unit]
Description=gunicorn daemon for myproject
Requires=gunicorn.socket
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myproject
EnvironmentFile=/etc/myproject.env
ExecStart=/srv/myproject/.venv/bin/gunicorn \
          --access-logfile - \
          --workers 3 \
          --bind unix:/run/gunicorn.sock \
          myproject.wsgi:application
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket
sudo systemctl status gunicorn.socket

Three workers is the right starting number for one vCPU: (2 × cores) + 1. Adding more does not add throughput, it adds memory.

Step 8 — nginx in front

/etc/nginx/sites-available/myproject:

server {
    listen 80;
    server_name example.com www.example.com;
    client_max_body_size 20M;

    location /static/ { alias /srv/myproject/staticfiles/; }
    location /media/  { alias /srv/myproject/media/; }

    location / {
        include proxy_params;
        proxy_pass http://unix:/run/gunicorn.sock;
    }
}
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl restart nginx

Step 9 — TLS

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

Certbot rewrites the server block, adds the redirect, and installs a renewal timer. Check it with systemctl list-timers | grep certbot.

Step 10 — A Celery worker, if you have one

[Unit]
Description=celery worker for myproject
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myproject
EnvironmentFile=/etc/myproject.env
ExecStart=/srv/myproject/.venv/bin/celery -A myproject worker -l info
Restart=always

[Install]
WantedBy=multi-user.target

It needs a broker, so sudo apt install redis-server first, and CELERY_BROKER_URL=redis://localhost:6379/0 in the env file.

Step 11 — The release routine

Every deploy from here is the same five lines. Put them in a script and stop typing them:

cd /srv/myproject
git pull
.venv/bin/pip install -r requirements.txt
set -a && . /etc/myproject.env && set +a
.venv/bin/python manage.py migrate --noinput
.venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart gunicorn

What you now own

The server costs $6 to $12 a month. That is the cheap part. The list below is the rest of the price, and it is worth reading before you decide this is the finished state:

  • Security updates. unattended-upgrades handles most of it. Kernel updates still need reboots, and reboots need a maintenance window.
  • The database. Backups are yours: pg_dump on a timer, stored somewhere that is not this server, and — the part everyone skips — a restore you have actually tested.
  • Monitoring. Nothing tells you the disk is full until Postgres stops writing.
  • Scaling. One box holds one box's worth of traffic. A second one means a load balancer, shared media storage, and session storage that is not local.
  • Certificates. The renewal timer works until the day the DNS moves.

For a side project or an internal tool, that trade is good and the control is genuinely worth having. For a product with customers, it is a part-time job you did not plan for — the managed options for Python are compared here, and the wider platform field here.

The same deploy, without the server

If what you wanted was the app live rather than a server to look after, the whole guide above collapses into this:

npm i -g @lizard-build/cli
cd myproject && lizard up
lizard add postgres redis

lizard up detects Django from manage.py, picks up pip, poetry, uv, or pipenv from your lockfile and your Python version from .python-version or requires-python, and lizardpack writes a real multi-stage Dockerfile on the build node. What comes back is a live URL with TLS already on it, backed by a long-lived process in a Firecracker micro-VM. lizard add postgres redis provisions PostgreSQL 18 and Redis 8; bind them to the service by reference and redeploy:

lizard secrets set DATABASE_URL='${{postgres.DATABASE_URL}}' \
                   REDIS_URL='${{redis.REDIS_URL}}' --service web
lizard redeploy --service web

Refs are stored by ID, so rotating a password never touches your code.

The rest maps one to one:

On the VPSOn Lizard
gunicorn under systemdThe service's start command
nginx and proxy_paramsIncluded, with TLS
certbot and its timerlizard domain example.com --service web
/etc/myproject.envlizard secrets set
manage.py migrate over SSHlizard ssh --service web -- python manage.py migrate
A Celery systemd unitlizard up --service worker --port 0
git pull and restartlizard git connect, then lizard add -r owner/repo, then git push to deploy
pg_dump on a timerManaged Postgres

One caveat worth stating: the Django default start command assumes your settings package is called app. If your project is myproject, set the start command yourself rather than letting the guess stand.


Skip the server, keep the Django

Free to start with $5 of credit and no card. Hobby is $5 a month.

Get started free → lizard.build/login


FAQ

How do I deploy a Django app on a VPS? Provision an Ubuntu server, harden SSH and enable a firewall, install Postgres and nginx, clone the project into a virtualenv, put configuration in an environment file, run migrate and collectstatic, serve the app with gunicorn under a systemd socket and service, put nginx in front of the socket, and issue a certificate with certbot. Every deploy after that is git pull, reinstall dependencies, migrate, collect static, restart gunicorn.

Do I need nginx if I use gunicorn? In production, yes. Gunicorn is an application server: it is not built to terminate TLS, serve static files efficiently, or absorb slow clients. nginx in front handles all three, and the gunicorn documentation recommends exactly this arrangement.

Why does Django return "permission denied for schema public"? PostgreSQL 15 removed the CREATE permission roles used to inherit on the public schema, and a database-level GRANT ALL PRIVILEGES does not cover schema rights. Connect to the database itself and run GRANT ALL ON SCHEMA public TO myuser;, or make the user the database owner.

How many gunicorn workers should I run? Start with (2 × CPU cores) + 1 — three workers on a single-vCPU VPS. More workers do not add throughput on the same core; they add memory use. If your app is I/O-bound rather than CPU-bound, gevent or uvicorn workers scale further than adding processes.

Where should I deploy a Django app? On a VPS if you want control and are willing to own patching, backups, and monitoring — expect $6 to $12 a month plus your time. On a platform if you want the app live without a server to look after: Lizard, Railway, Render, and DigitalOcean App Platform all deploy Django from source, provision Postgres, and run a Celery worker as a second service.

How do I run migrations on a deployed Django app? Once per release, before the new code serves traffic, and never inside the web process's startup — several workers would run them at once. On a VPS that is a line in the deploy script; on a platform it is a release command or a one-off shell, such as lizard ssh --service web -- python manage.py migrate.

How do I serve static files in production? Set STATIC_ROOT, run collectstatic at release time, and serve that directory from nginx with an alias. If you would rather not involve nginx, add WhiteNoise to the middleware and Django serves the collected files itself with correct cache headers — that is the simpler option on any platform host.

Is a VPS cheaper than a platform for Django? On the invoice, usually yes: $6 to $12 a month against $15 to $30. Once you price the hours spent on upgrades, backups, certificate renewals, and the evening the disk fills, the gap closes or reverses. The VPS wins on control and on predictable cost; the platform wins on the hours.


Sources

Build with AI. Ship with Lizard.

You don't need a platform team to go live. Your whole cloud, one CLI command away.

Try for free

No credit card required

We use cookies for essential site functionality and analytics. See our Cookie Policy.