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.

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(orpyproject.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.
- An administrator installs the Python runtime under Runtimes (the system Python 3, with virtual environments).
- 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.
- ZoPanel detects Django, Flask or FastAPI from
manage.py,requirements.txt,pyproject.tomlorPipfile. 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. - 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 over127.0.0.1. - If
migrateandcollectstaticshould 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.
Install ZoPanel free
One command on a fresh Ubuntu or Debian server. Up and running in minutes.
Install in 5 minutesKeep reading

Auto deploy from GitHub to a VPS: webhooks, Actions, zero downtime
Three ways to auto deploy from GitHub to a VPS: GitHub Actions over SSH, webhooks, or a panel with Git deploy, plus release scripts, health checks and rollback.

Deploy ASP.NET Core to a Linux VPS
How to deploy ASP.NET Core to Linux: install .NET 10 on Ubuntu 24.04, publish, run Kestrel under systemd behind nginx with SSL, by hand or with ZoPanel.

Deploy Laravel to a VPS: queues, scheduler and zero-downtime releases
How to deploy Laravel to a VPS on Ubuntu 24.04: PHP-FPM, nginx, .env, queue workers, the scheduler and zero-downtime releases, by hand or with ZoPanel.