# Python apps

> Deploy Django, Flask and FastAPI apps with a virtual environment, gunicorn or uvicorn workers sized to the package, static files, migrations and logs.

Source: https://zopanel.net/docs/python  
Updated: 2026-10-09

ZoPanel runs Python web apps as a service of the hosting account, behind nginx. Each release gets its own virtual environment in `.venv`, the app server (gunicorn or uvicorn) is added when it is missing, and the number of workers follows the package's CPU and memory. This page covers what is specific to Python; the deployment pipeline itself is described in [Git deploy](/docs/git-deploy).

## Prerequisites

- The Python toolchain on the server. The installer adds it by default; otherwise an administrator opens **Runtimes**, finds the **Python** card under **Other runtimes** and clicks **Install**. This installs `python3`, `python3-venv`, `python3-pip`, `python3-dev`, `build-essential`, `libpq-dev` and `pkg-config`.
- A website of type **Git deploy**, or any website with its **Deploy** tab.
- The account's package must include **Git deploy & apps**.

### Python version

By default apps use the Python 3 that ships with the operating system:

| System | Python |
| --- | --- |
| Ubuntu 22.04 | 3.10 |
| Ubuntu 24.04 | 3.12 |
| Debian 12 | 3.11 |
| Debian 13 | 3.13 |

To build apps with another Python, an administrator installs **uv** under **Runtimes** (the **uv (Python versions)** card). ZoPanel downloads the uv release and checks it against the SHA-256 checksum published with it. Then:

- The app's **Version** selects the Python. Auto-detect takes it from `.python-version` (`3.12` or `3.12.4` both mean 3.12); with **Auto-detect** off, pick it under **Version** (3.10 to 3.14 are listed).
- During the build, uv installs that Python for the hosting account, as that account, in its home folder (about 100 MB per version, downloaded once and reused by later builds). uv checks each download against the checksum recorded in uv itself. The `python3 -m venv .venv` of the install command then creates the virtual environment with it.
- The build log says which Python is used, for example "Using Python 3.13 (managed by uv; the system Python is 3.12)" or "Using the system Python 3.12".

Without uv, a `.python-version` that asks for another version is ignored, and the build log says so: "Python 3.13 was requested (.python-version or Version), but only the system Python 3.12 is available: building with 3.12…". Values uv cannot install (for example `pypy3.10`) are also ignored with a note. For an interpreter neither option provides, deploy with a Dockerfile (see [Docker deploy](/docs/docker-deploy)).

## Detection

A repository is treated as Python when it contains `manage.py`, `requirements.txt`, `pyproject.toml` or `Pipfile`, and none of the files checked earlier: `package.json`, `composer.json`, `Gemfile`, `pom.xml`/`build.gradle` or a `*.csproj`.

A Python web app that also has a `package.json` for its front-end tooling (Tailwind, Vite, esbuild) is still detected as Python when both are true:

- the repository has `manage.py`, or `requirements.txt`, `pyproject.toml` or `Pipfile` names `django`, `flask`, `fastapi`, `uvicorn` or `gunicorn`;
- `package.json` has no `start` script and no Node server framework (Next.js, Nuxt, NestJS, Express, Fastify, Koa, Hono, Remix, SvelteKit, Astro).

If `package.json` has a `build` script, the assets are built first: `npm ci && npm run build` (or `npm install && npm run build` without `package-lock.json`) is put in front of the build command, and Node.js must be installed under **Runtimes**. Otherwise the repository is detected as Node.js: turn **Auto-detect** off and set **Runtime** to `python` and the commands below yourself.

### Install command

| File found | Install command |
| --- | --- |
| `requirements.txt` | `python3 -m venv .venv && .venv/bin/pip install --upgrade pip -q && .venv/bin/pip install -r requirements.txt` |
| `pyproject.toml` | `python3 -m venv .venv && .venv/bin/pip install --upgrade pip -q && .venv/bin/pip install .` |
| `Pipfile` | `python3 -m venv .venv && .venv/bin/pip install pipenv -q && .venv/bin/pipenv install --deploy --system` |

When the dependency files do not mention the app server the framework needs, ` && .venv/bin/pip install gunicorn` (Django, Flask) or ` && .venv/bin/pip install uvicorn` (FastAPI) is appended. pip's download cache is kept in the account, so later builds are faster.

### Framework and start command

| Detected when | Framework | Proposed start command |
| --- | --- | --- |
| `manage.py` exists, or `django` is in the dependencies | Django | `.venv/bin/gunicorn <project>.wsgi:application --bind 127.0.0.1:$PORT` |
| `fastapi` in the dependencies | FastAPI | `.venv/bin/uvicorn <module>:<app> --host 127.0.0.1 --port $PORT` |
| `flask` in the dependencies | Flask | `.venv/bin/gunicorn <module>:<app> --bind 127.0.0.1:$PORT` |
| otherwise | Python | `.venv/bin/python main.py` (or `app.py`, `server.py`) |

- **Django:** `<project>` is the first folder that contains `wsgi.py` (`config` if none). The build command is `.venv/bin/python manage.py collectstatic --noinput || true`.
- **FastAPI and Flask:** ZoPanel looks for a line such as `app = FastAPI(` or `app = Flask(` in `main.py`, `app.py`, `server.py`, `app/main.py`, `src/main.py`, `wsgi.py` or `api.py`, and uses it as `module:variable` (for example `app.main:app`). If none is found, `main:app` is used.
- A `Procfile` with a `web:` line always provides the start command.

Click **Analyze repository** on the **Deploy** tab to check the result before deploying.

## Deploy an app

1. Open **Websites → New website**, choose **Git deploy**, enter the **Repository URL**, **Branch** and, if the app is in a subfolder, the **Root directory**. Click **Create website**.
2. Add your settings (database URL, secret key, allowed hosts) in **Environment variables** on the **Deploy** tab and click **Save & redeploy**.
3. To change a command, turn **Auto-detect** off in **Build & run**, edit it and click **Save & deploy**.

## Runtime environment

The start command runs from the release folder with:

| Variable | Value |
| --- | --- |
| `PORT` | The slot's port. Bind to `127.0.0.1:$PORT`. |
| `HOST` | `127.0.0.1` |
| `VIRTUAL_ENV`, `PATH` | `.venv` of the release, first in `PATH`, so `gunicorn` and `python` resolve to the virtual environment. |
| `PYTHONUNBUFFERED` | `1`, so `print()` and logging appear in the output at once. |
| `WEB_CONCURRENCY` | Number of workers, unless you set it: 2 × CPUs the package allows + 1, at most one per 128 MB of the package's memory (minimum 2), maximum 32. |

Both [gunicorn](https://docs.gunicorn.org/en/stable/settings.html#workers) and [uvicorn](https://www.uvicorn.dev/settings/) use `WEB_CONCURRENCY` as their default worker count. A `--workers` option in the start command takes precedence.

### Environment variables

Variables from the **Environment variables** card are available to the install and build commands and at runtime (up to 200, single-line values). Use **Paste .env** to add many at once. Read them with `os.environ`:

```python
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
```

## Django behind nginx

nginx terminates SSL and forwards requests with `X-Forwarded-Proto`. Add to `settings.py`:

```python
ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
```

Without `CSRF_TRUSTED_ORIGINS`, form posts over HTTPS fail with a 403 CSRF error.

### Static files

On a proxied website every request, `/static/` included, goes to the app. Serve static files from the app with [WhiteNoise](https://whitenoise.readthedocs.io/):

1. Add `whitenoise` to `requirements.txt`.
2. Add `"whitenoise.middleware.WhiteNoiseMiddleware"` right after `SecurityMiddleware` in `MIDDLEWARE`.
3. Set `STATIC_ROOT = BASE_DIR / "staticfiles"`.

The detected build command runs `collectstatic` on every deploy. It ends with `|| true`, so a failure does not stop the deployment: check the build log in **Tasks** if styles are missing.

### Uploaded media

Files written to the release folder are lost at the next deploy. Either add the media folder to **Persistent paths** (for example `media`), or store uploads in S3 with `django-storages` and ZoPanel's [S3 storage](/docs/apps). Django does not serve `MEDIA_ROOT` itself when `DEBUG` is off, so with a persistent folder you also need a view or WhiteNoise configuration that serves it.

## Migrations

ZoPanel does not run migrations automatically. Add them to the **Build command**, which runs with your environment variables:

```bash
.venv/bin/python manage.py collectstatic --noinput && .venv/bin/python manage.py migrate --noinput
```

For Flask-Migrate use `.venv/bin/flask db upgrade`, for Alembic `.venv/bin/alembic upgrade head`.

**Important:** migrations run before the new release passes its health check, while the previous release still serves traffic, and a **Rollback** does not undo them. Keep migrations backward compatible (add columns first, remove them in a later release).

## Zero-downtime releases, rollback and logs

Like every server app, a Python release starts in the idle slot and receives traffic only after the **Health check path** (default `/`) answers with a status below 500 within 90 seconds. A 400 or 404 passes; a 500 does not. The **Releases** card keeps the last 5 builds for **Rollback**.

- **Application output** on the **Deploy** tab shows the last 400 lines of the app's output; turn on **Live** to follow it.
- Build logs are in **Tasks**; nginx logs are on the **Logs** tab.

Each release has its own `.venv`, rebuilt on every deploy. Keep data in a database or a persistent path, never inside `.venv` or the release folder.

## Troubleshooting

| Message or symptom | Cause and fix |
| --- | --- |
| `the app did not respond on port … within 90s (it must listen on $PORT)` | The server binds another address or port. Use `--bind 127.0.0.1:$PORT` (gunicorn) or `--host 127.0.0.1 --port $PORT` (uvicorn). |
| `the app exited during startup` | Read **Application output**. Typical causes: wrong `module:app` in the start command, a missing environment variable, an import error. |
| `install failed: …` with `No module named venv` or `ensurepip is not available` | The Python toolchain is missing. An administrator installs **Python** under **Runtimes**. |
| The build log says "only the system Python … is available" | The project asks for another Python in `.python-version`. Ask the administrator to install **uv** under **Runtimes**, or remove `.python-version` if the system Python is fine. |
| `could not install Python 3.x with uv` | uv could not download that Python (no network, an unknown version, or the account's disk quota is full). Read the lines above it in the build log. |
| `install failed: …` while building `mysqlclient` | The MySQL client headers are not installed. Use `PyMySQL`, or ask the administrator to run `apt-get install -y default-libmysqlclient-dev pkg-config`. |
| `Failed to find attribute 'app' in 'main'` | The app object has another name or module. Fix the start command, for example `.venv/bin/gunicorn myapp.wsgi:app --bind 127.0.0.1:$PORT`. |
| Django returns `Bad Request (400)` for the domain | Add the domain to `ALLOWED_HOSTS`. |
| Pages without CSS | Static files are not served: set up WhiteNoise and check that `collectstatic` succeeded in the build log. |
| The app is killed under load | Too many workers for the package's memory. Set `WEB_CONCURRENCY` lower in **Environment variables**. |

## Related

- [Git deploy](/docs/git-deploy)
- [Node.js and Python apps](/docs/apps-node-python)
- [Databases](/docs/databases)
- [Packages and limits](/docs/packages-limits)
- [Django deployment checklist](https://docs.djangoproject.com/en/stable/howto/deployment/checklist/)
- [FastAPI deployment](https://fastapi.tiangolo.com/deployment/)
