# Python

> Running Flask, FastAPI or any other Python web application — the build, the server and what to read.

A Python application serves its own HTTP, through Gunicorn or Uvicorn, and
replaces PHP-FPM and nginx rather than joining them. In front of it is the
platform's proxy, which terminates TLS, routes the hostname and compresses
responses. Django has a page of its own: [Django](framework-django.md).

## A Flask application

```yaml
version: 1
type: python

runtime:
  python: '3.14'

services:
  - postgres: '18'

build:
  steps:
    - uv sync --locked --no-dev --no-editable
  cache:
    - .venv

deploy:
  steps:
    - python -m flask db upgrade
  on_failure: rollback

start: python -m gunicorn app:app

health:
  path: /healthz
```

**Run everything as `python -m …`** — the server, workers, cron, deploy steps.
The build installs into `.venv` in the checkout, and the release is that
checkout moved: `python` resolves to the release's own environment wherever it
runs, while a console script such as `bin/gunicorn` still points at the path it
was built in.

`--no-editable` matters for the same reason: an editable install writes the
build's path into the environment, and that path is gone once the build is a
release.

### pip instead of uv

uv is in the image and is what these examples use. pip works as well — create
the environment in the build and install into it:

```yaml
build:
  steps:
    - python -m venv .venv
    - .venv/bin/pip install -r requirements.txt
  cache:
    - .venv
```

Poetry is not in the image. `poetry export -f requirements.txt` gives pip a
file to install from, or `uv` reads the same `pyproject.toml`.

## An ASGI application

FastAPI, Starlette and anything else ASGI run under Gunicorn with Uvicorn's
worker, which keeps Gunicorn's process management — or under Uvicorn alone:

```yaml
start: python -m gunicorn main:app -k uvicorn_worker.UvicornWorker
```

```yaml
start: python -m uvicorn main:app --port $PORT
```

Uvicorn already listens on every address (`UVICORN_HOST`); the port is yours to
pass, and `$PORT` reaches it.

## What to read

```
PORT                what to listen on — Gunicorn reads it on its own
DATABASE_URL        one connection string, postgresql://… or mysql://…
REDIS_HOST          the cache, when the stack runs one — with REDIS_PORT
VALLIC_PRIVATE_DIR  a directory that outlives every release — where uploads go
VALLIC_HOSTNAMES    every hostname routed here, comma-separated
SMTP_HOST           the mail relay, on port 25 — see OpenSMTPD
```

SQLAlchemy, psycopg and most other drivers take `DATABASE_URL` as it is. The
parts are there too — see [Variables](variables.md).

**Bind every address, not `localhost`.** The proxy reaches your process across
the container boundary, and a server listening on `127.0.0.1` inside its own
container is reachable by nothing. Gunicorn does this when `PORT` is set.

**Trust the proxy's headers.** It sets `X-Forwarded-For` and
`X-Forwarded-Proto`, and `FORWARDED_ALLOW_IPS` is set so Gunicorn and Uvicorn
believe them; a framework that builds absolute URLs wants the same — Flask's
`ProxyFix`, for one.

## What is writable

The release is mounted **read-only** while the application runs. Anything your
application writes and expects to keep goes under `VALLIC_PRIVATE_DIR`
(`private/` at the root of the release), which is kept outside the release and
linked back into every one, or under a mount you declare:

```yaml
mounts:
  - uploads
```

Bytecode is compiled in the build, which can write; at runtime Python does not
try to. Output to stdout and stderr is collected — see [Logs](logs.md).

## Workers

Celery, RQ and Dramatiq run as workers: the application's own image and
release, supervised, with no port and no hostname.

```yaml
workers:
  - name: celery
    command: python -m celery -A app worker --loglevel=INFO --concurrency=2
```

A worker machine, where the environment has one, runs them; the web machines
then run none.

## Cron

Nothing is scheduled for you; periodic work is yours to declare, and runs in
the application's container:

```yaml
cron:
  - name: cleanup
    schedule: '15 3 * * *'
    command: python -m app.cleanup
```

## More than one web server

Once an environment has two, consecutive requests from one visitor can land on
different machines, so anything kept in a process's memory — a session store,
a cache — is not there on the next request. Keep them in Valkey, at
`REDIS_HOST` and `REDIS_PORT`.
