# 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.

Source: https://zopanel.net/blog/deploy-python-app-vps  
Updated: 2026-10-21

## 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](/blog/point-domain-to-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](https://docs.djangoproject.com/en/dev/faq/install/), 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

```bash
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

```bash
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](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys) 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`):

```bash
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](https://docs.djangoproject.com/en/stable/howto/deployment/checklist/) asks for:

```python
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:

```bash
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`:

```ini
[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:

```bash
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](https://docs.gunicorn.org/en/21.0.1/design.html) 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`:

```nginx
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;
    }
}
```

```bash
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

```bash
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](/blog/free-ssl-lets-encrypt).

### Step 7: Shipping updates

For every new version:

```bash
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](/docs/apps-node-python), 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](/blog/git-auto-deploy) and the [Git deploy docs](/docs/git-deploy).

## 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](/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.
