Deploy FastAPI to a VPS with uvicorn and Postgres
Deploy FastAPI to your own VPS: ox detects the uvicorn start command, installs from uv.lock, runs Alembic migrations, and adds PostgreSQL from one ox.toml.
Last updated 2026-10-08
To deploy FastAPI to a VPS with ox, add the repo as a project and deploy: ox detects the uvicorn start command, installs your packages from uv.lock, poetry.lock or requirements.txt, and runs Alembic migrations after a database snapshot. uvicorn runs as a systemd service behind Caddy, which serves HTTPS on your domain. Write an ox.toml, as below, when you want a domain, a health path or PostgreSQL.
What ox detects in a FastAPI repo
ox reads the Python files at the repo root. It proposes:
- The install step from your lockfile:
uv sync --frozen --no-devforuv.lock,poetry install --only main --no-rootforpoetry.lock,uv venv --allow-existing && uv pip install -r requirements.txtforrequirements.txt, oruv sync --no-devfor apyproject.tomlalone. - The Python version from
.python-version,.tool-versionsormise.toml. - A start command, when
uvicornorfastapi[standard]is a dependency and one ofmain.py,app.py,app/main.pyorsrc/main.pycontainsFastAPI(. ox reads the object's name from the line that makes it, such asapp = FastAPI(). Forapp/main.pywith auv.lockthat isuv run uvicorn app.main:app --host 127.0.0.1 --port $PORT. - The migrate step
uv run alembic upgrade head, whenalembic.iniis in the repo andalembicis a dependency. - When the repo has no ox.toml: PostgreSQL when
psycopg,psycopg2orasyncpgis a dependency, and Redis whenredisis.
When ox cannot be sure, it proposes no start and ox check prints a hint saying why: uvicorn is not a dependency, the app is made inside a function, or the file makes more than one FastAPI object. Set [app] start yourself then.
The ox.toml for FastAPI
domains = ["api.example.com"]
[app]
start = "uv run uvicorn app.main:app --host 127.0.0.1 --port $PORT --workers 2"
health = "/health"
[build]
install = "uv sync --frozen --no-dev"
migrate = "uv run alembic upgrade head"
[services]
postgres = {}domains: the names your API answers on. ox gets the HTTPS certificate for each.start: runs uvicorn on the port ox picks, with two worker processes.health: a path that must answer with a 2xx or 3xx status before a new release gets traffic.install: installs your packages fromuv.lock.migrate: runs your Alembic migrations on every deploy, after ox takes a snapshot of the database.postgres: a database for this app.
The health route should answer without touching the database, so it stays fast.
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"ok": True}PostgreSQL with SQLAlchemy and Alembic
With postgres = {}, ox creates a database and gives your app DATABASE_URL ([services]). Add redis = {} and you also get REDIS_URL. Once a repo has an ox.toml, ox runs only the services it lists.
DATABASE_URL starts with postgres://. SQLAlchemy wants the driver in the name, so change the start of it when you read it. When SQLAlchemy is a dependency, ox check prints this line for your driver as a hint.
import os
from sqlalchemy import create_engine
url = os.environ["DATABASE_URL"].replace("postgres://", "postgresql+psycopg://", 1)
engine = create_engine(url)Point Alembic's env.py at the same url, so migrations and the app use one database.
Variables and secrets
ox also gives you PORT, HOST, PUBLIC_URL and PUBLIC_HOST. Set your own keys, like SECRET_KEY, on the dashboard or with ox vars set <project> SECRET_KEY, which asks for the value. Every key named in .env.example must be set before a deploy can start. ox never reads your .env file.
Check and deploy the FastAPI app
ox check # in the repo: prints the plan and "Ready to deploy."
ox deploy <project> --wait # stream the deploy, exit with its result
ox logs <project> --follow # the app's own logsFor the ox.toml above, ox check ends like this:
Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, DATABASE_URL
Set on the dashboard before the first deploy: SECRET_KEY
hint: SQLAlchemy (from uv.lock) rejects DATABASE_URL's postgres:// scheme; in your code, use os.environ["DATABASE_URL"].replace("postgres://", "postgresql+psycopg://", 1)
Ready to deploy.If the FastAPI deploy fails
nothing to run: ox found no start command, static site, worker, or cron job; set [app] start (or declare [workers]).ox checksays this when ox found no start. The hint above it says why, such asFastAPI found in main.py, but uvicorn is not a dependency. Adduvicornto your dependencies, or set[app] start. With no hint, your app file is somewhere ox does not look.the app exited while starting (…), orGET http://127.0.0.1:<port>/health did not answer within 120s. Read the app's log above it. A wrong module path such asmain:appinstead ofapp.main:app, or aKeyErrorfor a missing variable, are the usual causes.migrate: uv run alembic upgrade head: …withfix: the command's own output is above; fix it in the repo and push. Alembic failed. Run the migration against a local database, fix it, and push.
If an older release is live, it keeps serving while you fix any of these. More causes and fixes are in Troubleshooting, and every key is in the config reference.
Next steps
- Set SECRET_KEY and other variables from the dashboard or the CLI.
- Add your own domain with HTTPS: one A record, and Caddy gets the certificate.
- Read and search the app's logs, live or for a time range.
- Roll back a bad deploy to a kept release without a rebuild.
- See the daily PostgreSQL backups and restore one.