Back to blog

How to Deploy Django on a VPS with Gunicorn and Nginx (Plus Flask)

IM Host EditorialOctober 10, 20269 min read
How to Deploy Django on a VPS with Gunicorn and Nginx (Plus Flask)

To deploy Django on a VPS for production, run it with Gunicorn as a systemd service, put Nginx in front as a reverse proxy that also serves static files, keep secrets in an environment file, connect to PostgreSQL locally, and add HTTPS with Certbot. Never use manage.py runserver in production: it is single-threaded, has no process supervision and is not built for hostile traffic. The steps below take a working project from a Git repository to a live HTTPS site on Ubuntu 22.04 or 24.04. Flask differences are at the end.

Placeholders used throughout: project package myproject, system user and service myapp, domain example.com (replace with your domain, and point its A record to the server first). Do the basic hardening from our guide to securing a Linux VPS with SSH keys, UFW and fail2ban before you start.

How the pieces fit

  • Nginx accepts connections on ports 80 and 443, terminates TLS, serves /static/ and /media/ straight from disk, and forwards everything else.

  • Gunicorn runs several Python worker processes that execute your Django code and listen on a Unix socket.

  • systemd starts Gunicorn at boot, restarts it if it crashes, and sends its output to the journal.

  • PostgreSQL listens on localhost only.

1. Install system packages

sudo apt update
sudo apt install -y python3-venv python3-dev nginx postgresql git

Python itself comes with Ubuntu (3.10 on 22.04, 3.12 on 24.04). If your project needs a different version, check that before going further.

2. Create an app user, fetch the code, build a virtualenv

A dedicated user without a login shell limits what a bug in the app can touch.

sudo useradd --system --create-home --home-dir /srv/myapp --shell /usr/sbin/nologin myapp
sudo -u myapp git clone https://git.example.com/myproject.git /srv/myapp/src
sudo -u myapp python3 -m venv /srv/myapp/venv
sudo -u myapp /srv/myapp/venv/bin/pip install --upgrade pip
sudo -u myapp /srv/myapp/venv/bin/pip install -r /srv/myapp/src/requirements.txt gunicorn "psycopg[binary]"

Pin versions in requirements.txt (including Gunicorn once it works) so that a rebuild next month installs the same thing. Django 4.2 and newer support psycopg 3; older projects use psycopg2-binary. The virtualenv also avoids the "externally managed environment" error you get from system-wide pip on Ubuntu 24.04.

3. Create the PostgreSQL database

sudo -u postgres psql
CREATE ROLE myapp WITH LOGIN PASSWORD 'change-this-long-password';
CREATE DATABASE myapp OWNER myapp;
\q

Making myapp the database owner avoids the schema permission errors that newer PostgreSQL versions raise on migrate. For memory settings, backups and access rules, see our PostgreSQL on a VPS guide to tuning, backups and security.

4. Keep secrets in an environment file

sudo mkdir -p /etc/myapp
sudo nano /etc/myapp/myapp.env
DJANGO_SETTINGS_MODULE=myproject.settings
DJANGO_SECRET_KEY=paste-a-long-random-value-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=change-this-long-password
DB_HOST=127.0.0.1
DB_PORT=5432
python3 -c "import secrets; print(secrets.token_urlsafe(50))"
sudo chown root:myapp /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.env

The first command block creates the file; the last one generates a secret key and locks the file so only root and the app group can read it. Keep values free of spaces, or quote them, because both systemd and the shell parse this file. Never commit it to Git.

Then make settings.py read from the environment:

import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": os.environ.get("DB_HOST", "127.0.0.1"),
        "PORT": os.environ.get("DB_PORT", "5432"),
        "CONN_MAX_AGE": 60,
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = "/var/www/myapp/static"
MEDIA_URL = "/media/"
MEDIA_ROOT = "/var/www/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 to trust the X-Forwarded-Proto header that Nginx sets, so it knows the original request was HTTPS. Only set it when Nginx is in front and overwrites that header, as in the config below.

5. Migrate, collect static files, run the deploy check

sudo mkdir -p /var/www/myapp/static /var/www/myapp/media
sudo chown myapp:myapp /var/www/myapp/static /var/www/myapp/media

sudo -u myapp bash -c 'set -a; . /etc/myapp/myapp.env; set +a; cd /srv/myapp/src; /srv/myapp/venv/bin/python manage.py migrate'
sudo -u myapp bash -c 'set -a; . /etc/myapp/myapp.env; set +a; cd /srv/myapp/src; /srv/myapp/venv/bin/python manage.py collectstatic --noinput'
sudo -u myapp bash -c 'set -a; . /etc/myapp/myapp.env; set +a; cd /srv/myapp/src; /srv/myapp/venv/bin/python manage.py check --deploy'

Static files go to /var/www/myapp rather than the app's home directory, because Ubuntu creates home directories with mode 750 and Nginx (user www-data) could not read them. check --deploy lists missing production settings; fix every warning you understand, and look up the rest.

6. Run Gunicorn as a systemd service

Create /etc/systemd/system/myapp.service:

[Unit]
Description=myapp (Gunicorn)
After=network.target postgresql.service

[Service]
Type=notify
NotifyAccess=main
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/src
EnvironmentFile=/etc/myapp/myapp.env
RuntimeDirectory=myapp
ExecStart=/srv/myapp/venv/bin/gunicorn --workers 3 --timeout 60 --bind unix:/run/myapp/gunicorn.sock --access-logfile - myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=30
Restart=on-failure

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

Notes on the choices:

  • Workers: the usual starting point is (2 x vCPU) + 1. Each sync worker holds a full copy of your app in memory, often somewhere between 50 and 150 MB for a typical Django project, so check RAM with free -h after starting.

  • RuntimeDirectory makes systemd create /run/myapp for the socket on every start, owned by the app user.

  • ExecReload sends HUP: Gunicorn starts new workers with the new code and gracefully stops the old ones, so systemctl reload myapp deploys without dropping requests, as long as you do not use --preload.

  • --timeout 60 kills a worker stuck for 60 seconds. Long jobs such as reports or imports belong in a background queue, not a web request.

7. Configure Nginx as the reverse proxy

Create /etc/nginx/sites-available/myapp:

upstream myapp_app {
    server unix:/run/myapp/gunicorn.sock fail_timeout=0;
}

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    client_max_body_size 20M;

    location /static/ {
        alias /var/www/myapp/static/;
        expires 30d;
        access_log off;
    }

    location /media/ {
        alias /var/www/myapp/media/;
    }

    location / {
        proxy_pass http://myapp_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
        proxy_read_timeout 60s;
    }
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Watch the trailing slashes: location /static/ with alias /var/www/myapp/static/; must both end in /, or files return 404. client_max_body_size sets the upload limit; Nginx's default is only 1 MB.

8. Add HTTPS with Certbot

sudo ufw allow 'Nginx Full'
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Certbot edits the Nginx server block to add port 443 and a redirect from HTTP, and installs a timer that renews certificates automatically. After this, the site should load on https:// and the admin login should work without CSRF errors.

9. Logs and the errors you will meet

sudo journalctl -u myapp -f
sudo journalctl -u myapp --since "1 hour ago"
sudo tail -f /var/log/nginx/error.log
  • 502 Bad Gateway: Gunicorn is not running or the socket path differs between the unit and Nginx. Check systemctl status myapp first.

  • 400 Bad Request: the domain is missing from DJANGO_ALLOWED_HOSTS.

  • CSRF verification failed after HTTPS: add the https origin to CSRF_TRUSTED_ORIGINS and confirm Nginx sends X-Forwarded-Proto.

  • Page without styles: collectstatic was not run, or the alias path is wrong.

  • Redirect loop: SECURE_SSL_REDIRECT = True without the proxy header setting.

10. Deploy updates with almost no downtime

Save this as /usr/local/bin/deploy-myapp and make it executable:

#!/usr/bin/env bash
set -euo pipefail
APP=/srv/myapp
run() { sudo -u myapp bash -c "set -a; . /etc/myapp/myapp.env; set +a; cd $APP/src; $*"; }

run "git pull --ff-only"
run "$APP/venv/bin/pip install -r requirements.txt"
run "$APP/venv/bin/python manage.py migrate --noinput"
run "$APP/venv/bin/python manage.py collectstatic --noinput"
sudo systemctl reload myapp

For migrations that drop or rename columns, deploy in two steps (code that works with both schemas first, then the migration) so old workers do not crash during the reload. The same pattern of a supervised process behind Nginx applies to other stacks; compare it with our Node.js on a VPS guide with PM2 and Nginx.

Flask variant

Everything above applies, with three changes. There is no migrate or collectstatic (use flask db upgrade if you use Flask-Migrate). Create a WSGI entry point that also trusts the proxy headers, and point Gunicorn at it:

# /srv/myapp/src/wsgi.py
from werkzeug.middleware.proxy_fix import ProxyFix
from app import create_app

app = create_app()
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
ExecStart=/srv/myapp/venv/bin/gunicorn --workers 3 --bind unix:/run/myapp/gunicorn.sock --access-logfile - wsgi:app

For static files, copy the app's static folder to /var/www/myapp/static during deploy, or keep the same alias pointing at any directory Nginx can read.

Choosing the server

A small Django or Flask app with PostgreSQL on the same machine runs comfortably on 2 vCPU and 2 to 4 GB of RAM as a rule of thumb; more workers, Celery and larger databases push that up. An IM HOST Linux VPS gives full root on KVM with Ubuntu or Debian images, from $5/mo. When CPU-heavy requests or a busy database need predictable performance, Cloud VPS with dedicated vCPU and RAM lets you move between plans as traffic grows.

Frequently asked questions

Why use Gunicorn and Nginx instead of runserver?

runserver is a development server. Gunicorn runs multiple supervised worker processes, and Nginx handles TLS, slow clients and static files far more efficiently than Python can.

How many Gunicorn workers should I use?

Start with (2 x vCPU) + 1 and adjust to available RAM. If memory is tight, fewer workers with --threads 2 is a reasonable alternative for I/O-bound apps.

Should I use a Unix socket or 127.0.0.1:8000?

Both work. A Unix socket avoids TCP overhead and is never reachable from the network. A localhost port is easier to test with curl. Pick one and use the same value in the systemd unit and Nginx.

Can I use Docker instead?

Yes. The same layers apply inside containers: Gunicorn in the app container, Nginx in front, PostgreSQL with a persistent volume. The systemd approach is simpler for a single app on one server.

If you want a clean server to try this on, start with an IM HOST Linux VPS. There are no setup fees, it renews at the same price, and our engineers are available 24/7 if a step does not behave as described.

More from our blog

Discover more practical guides and product insights from the IM Host team.

View all articles