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.