Skip to main content

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.

A Flask application

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:

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:

start: python -m gunicorn main:app -k uvicorn_worker.UvicornWorker
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.

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:

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.

Workers

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

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:

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.