How to deploy Django and Flask to a VPS: gunicorn, nginx, SSL

Deploy Django or Flask to a VPS on Ubuntu 24.04 or Debian 12 - virtualenv, gunicorn, systemd, nginx and SSL - by hand step by step, or with ZoPanel Git deploy.

Diagram: requests passing through nginx to gunicorn running a Django or Flask app on a VPS

Key takeaways

  • Never run runserver or flask run in production; use gunicorn (WSGI) or uvicorn (ASGI) behind nginx.
  • Ubuntu 24.04 ships Python 3.12 and Debian 12 ships 3.11, so Debian 12 can run Django 5.2 LTS but not Django 6.x.
  • Install dependencies in a virtual environment; system-wide pip is blocked by PEP 668.
  • Bind the app to 127.0.0.1, read settings from environment variables and run it as its own user under systemd.

To deploy Django or Flask to a VPS you need four pieces: a virtual environment holding the project's dependencies, an application server such as gunicorn to run the Python code, systemd to keep that process alive, and nginx in front to handle the domain, HTTPS and static files. Never put manage.py runserver or flask run in production: both projects say plainly that their development servers are not built to be secure or stable.

This guide walks through the manual setup on Ubuntu 24.04 and Debian 12 with working configuration files, the errors people hit most often, and how to do the same thing with ZoPanel's Git deploy when you would rather not write systemd units and nginx blocks for every project.

What does a production Python deployment look like?

A Python web app in production usually has three layers:

Layer Job Typical choice
Web server / reverse proxy Accepts internet traffic, terminates HTTPS, serves static files, forwards dynamic requests nginx
Application server Runs your Python code and talks to nginx over local HTTP gunicorn (WSGI), uvicorn (ASGI)
Application Your code Django, Flask, FastAPI

Django and Flask are WSGI applications, so gunicorn is the usual pick. For FastAPI, or Django with websockets and ASGI, use uvicorn. The app should listen on 127.0.0.1 only and never be exposed directly; all traffic goes through nginx.

What do you need before you deploy?

  • A VPS running Ubuntu 24.04 or Debian 12 with sudo access. A small Django app with PostgreSQL runs on 1 GB of RAM; 2 GB gives room for Redis or a Celery worker.
  • A domain pointing at the server's IP (an A record). If that is not done yet, see how to point a domain to a VPS.
  • Your code in Git, with a requirements.txt (or pyproject.toml) listing dependencies.

Watch the Python version

This trips people up more than anything else:

OS Default Python Django versions that install
Ubuntu 24.04 3.12 5.2 LTS, 6.0, 6.1
Debian 12 3.11 5.2 LTS only (Django 6.x needs Python 3.12+)

According to Django's compatibility table, Django 6.0 and 6.1 support Python 3.12 and newer only. If your laptop runs Python 3.13 and requirements.txt pins Django==6.1, pip install on Debian 12 will fail to find a matching version. Pin Django 5.2 LTS, or pick Ubuntu 24.04 for a new server.

Both systems also mark the system Python as "externally managed" (PEP 668), so pip install outside a virtual environment fails with externally-managed-environment. Do not work around it with --break-system-packages; use a venv as shown below.

How to deploy Django or Flask to a VPS by hand

The commands assume a Django project called myproject, living in /srv/myproject and running as a user called app. For Flask, only the gunicorn entry point changes (step 4).

Step 1: Install system packages and create a dedicated user

sudo apt update
sudo apt install -y python3-venv python3-dev build-essential nginx git
sudo apt install -y postgresql libpq-dev   # if you use PostgreSQL
sudo adduser --system --group --home /srv/myproject app

Run the app as its own user, never as root. If the code has a hole, an attacker gets the app user's rights and nothing more.

Step 2: Fetch the code and create a virtual environment

sudo -u app git clone https://github.com/you/myproject.git /srv/myproject/app
cd /srv/myproject/app
sudo -u app python3 -m venv .venv
sudo -u app .venv/bin/pip install -r requirements.txt gunicorn

For a private repository, create an SSH key pair for the app user and add the public key to the repository as a deploy key. On GitHub, deploy keys are read-only by default and tied to a single repository, which is exactly what you want.

Step 3: Production settings

Keep every secret in environment variables. Create /srv/myproject/.env (mode 600, owned by app):

DJANGO_SECRET_KEY=a-long-random-string
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DATABASE_URL=postgresql://myproject:password@127.0.0.1:5432/myproject

In settings.py, read them and set what Django's deployment checklist asks for:

import os
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
STATIC_ROOT = BASE_DIR / "staticfiles"

SECURE_PROXY_SSL_HEADER is only safe when nginx always overwrites X-Forwarded-Proto, as the config in step 5 does. Then run:

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

check --deploy lists the security settings you are still missing. Read its output; do not skip it.

Step 4: Run gunicorn under systemd

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

[Unit]
Description=myproject (gunicorn)
After=network.target

[Service]
User=app
Group=app
WorkingDirectory=/srv/myproject/app
EnvironmentFile=/srv/myproject/.env
ExecStart=/srv/myproject/app/.venv/bin/gunicorn myproject.wsgi:application \
    --bind 127.0.0.1:8000 --workers 3 --timeout 60
Restart=on-failure

[Install]
WantedBy=multi-user.target

For Flask, replace myproject.wsgi:application with app:app (module app.py, variable app). Then:

sudo systemctl daemon-reload
sudo systemctl enable --now myproject
sudo systemctl status myproject
curl -I http://127.0.0.1:8000/

How many workers? The gunicorn documentation suggests (2 × CPU cores) + 1 as a starting point, tuned under real load. Each worker is a full Python process, though, and a Django worker can use tens of megabytes or more depending on the app. On a 1 GB VPS, three workers is a saner start than the formula. When memory runs short, cut workers before you buy a bigger server.

Step 5: nginx as the reverse proxy

Create /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/app/staticfiles/;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

nginx runs as www-data, so staticfiles and every parent directory must be readable and traversable by it. A 403 on CSS and JavaScript is almost always directory permissions. Alternatively, let Django serve its own static files with the WhiteNoise library and drop the /static/ block.

Step 6: Add HTTPS

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

Certbot edits the nginx config, adds the HTTPS redirect and schedules renewals. More detail in free SSL with Let's Encrypt on a VPS.

Step 7: Shipping updates

For every new version:

cd /srv/myproject/app
sudo -u app git pull
sudo -u app .venv/bin/pip install -r requirements.txt
# run migrate and collectstatic as in step 3
sudo systemctl restart myproject

This gives a few seconds of downtime while gunicorn restarts, and if the new release crashes on startup the site stays down until you fix it. Fine for a side project. For anything with paying users you want zero-downtime releases and a rollback button, which is where the next section comes in.

Common errors when deploying Django and Flask

Symptom Usual cause
400 Bad Request on every page The domain is missing from ALLOWED_HOSTS
403 CSRF failure on forms over HTTPS Missing CSRF_TRUSTED_ORIGINS, or nginx is not sending X-Forwarded-Proto
502 Bad Gateway gunicorn is down or bound to the wrong port; check journalctl -u myproject -n 100
Pages without CSS collectstatic not run, wrong alias path, or directory permissions
HTTPS redirect loop SECURE_SSL_REDIRECT is on but Django cannot tell the request was HTTPS
Workers killed with WORKER TIMEOUT A request runs past --timeout; move heavy work to a queue (Celery, RQ)

Deploying Python apps with ZoPanel

If the server runs ZoPanel, most of the steps above happen for you, and you get zero-downtime releases that are tedious to build by hand.

  1. An administrator installs the Python runtime under Runtimes (the system Python 3, with virtual environments).
  2. Go to Websites → New website, choose Git deploy, enter the Repository URL and Branch, and keep Free SSL (Let's Encrypt) on. For a private repository, copy the key from Deploy → Deploy key (private repositories) into the repository's deploy keys.
  3. ZoPanel detects Django, Flask or FastAPI from manage.py, requirements.txt, pyproject.toml or Pipfile. The install step creates .venv, installs dependencies and adds gunicorn or uvicorn if missing. The proposed start command for Django is .venv/bin/gunicorn <project>.wsgi:application --bind 127.0.0.1:$PORT, and for Flask .venv/bin/gunicorn app:app --bind 127.0.0.1:$PORT. Click Analyze repository to preview it.
  4. Put DJANGO_SECRET_KEY, the allowed hosts and the database URL in Environment variables (Paste .env adds many at once). Create the PostgreSQL or MariaDB database on the Databases page and connect over 127.0.0.1.
  5. If migrate and collectstatic should run on every deploy, put them in the Build command, chained with &&.

The one rule that matters: the app must listen on 127.0.0.1:$PORT and never hard-code a port, because ZoPanel runs two slots on two ports. Each release is built in its own folder, started in the idle slot, and must answer the Health check path with a status below 500 within 90 seconds before nginx switches traffic to it. If the new release fails, the old one keeps serving. The Releases card keeps the last 5 builds, and Rollback uses the same zero-downtime switch.

Because two releases briefly run side by side, keep migrations backward compatible (add a column in one release, drop the old one in the next), and keep user uploads in a Persistent path such as media, not inside the release folder.

Builds run as the hosting account, inside the package's CPU and memory limits, unable to write outside the account's home. Application output on the Deploy tab shows the latest 400 lines from the app; a 502 usually means the app is not listening on $PORT, and Run diagnostics points it out. To deploy on every push, see auto deploy from GitHub to a VPS and the Git deploy docs.

Conclusion

Deploying Python to a VPS is straightforward once the three layers are clear: nginx outside, gunicorn in the middle, your app inside, all running as a non-root user under systemd. Doing it by hand once is the best way to understand the system. Once you have several projects, or need zero-downtime releases and one-click rollback, a panel that does this for you saves hours every month.

ZoPanel's Free plan gives you 3 hosting accounts, 10 websites and 10 databases with no time limit, and Git deploy works fully on Free, enough to try it with your own Django or Flask project. See pricing when you need more.

Frequently asked questions

Can I use python manage.py runserver on a VPS?

No. The Django and Flask development servers are for local development only and are not designed to be secure, stable or efficient. In production use gunicorn (WSGI) or uvicorn (ASGI) behind nginx.

Should I use gunicorn or uvicorn?

Standard Django and Flask apps are WSGI, so use gunicorn. FastAPI, Starlette, or Django using websockets and ASGI need uvicorn. Either way, put nginx in front.

Why does pip fail with externally-managed-environment?

Ubuntu 24.04 and Debian 12 mark the system Python as managed by apt (PEP 668) so pip cannot overwrite packages the OS depends on. Create a virtual environment with python3 -m venv .venv and install into it; avoid --break-system-packages on a long-lived server.

How much RAM does a Django VPS need?

A small Django app with two or three gunicorn workers and PostgreSQL fits in 1 GB of RAM. Each worker is a separate process, so memory grows roughly with the worker count; with Redis, Celery or several projects, start at 2 GB.

How is deploying Flask different from Django?

Almost not at all: venv, gunicorn, systemd and nginx are the same. The WSGI entry point differs (app:app instead of myproject.wsgi:application), and Flask has no collectstatic because static files already live in its static folder. Behind a proxy, use Werkzeug's ProxyFix so Flask sees the right scheme and client IP.