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

On this page

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 like the one below when you want a domain, a health path or PostgreSQL.

ox reads the ox.toml on this page, makes the plan on the card, and starts uv run uvicorn app.main:app --host 127.0.0.1 --port $PORT --workers 2 on your server. The cylinder is the service the file asks for: postgres.

What ox detects in a FastAPI repo

ox reads the Python files at the repo root and proposes:

  • The install step, from your lockfile: uv sync --frozen --no-dev for uv.lock, uvx --from 'poetry==2.5.1' poetry install --only main --no-root for poetry.lock (Poetry runs in the build, at the version your version file pins), uv venv --allow-existing && uv pip install -r requirements.txt for requirements.txt, or uv sync --no-dev for a pyproject.toml alone.
  • The start command, when uvicorn or fastapi[standard] is a dependency and one of main.py, app.py, app/main.py or src/main.py contains FastAPI(. ox reads the object's name from the line that makes it, such as app = FastAPI(). For app/main.py with a uv.lock, the start is uv run uvicorn app.main:app --host 127.0.0.1 --port $PORT.
  • The migrate step uv run alembic upgrade head, when alembic.ini is in the repo and alembic is a dependency.
  • The Python version, from .python-version, .tool-versions or mise.toml. With none of them, ox uses its default version, and ox check prints it. For uv.lock or a pyproject.toml alone, ox uses its default only when requires-python allows it. Otherwise it takes the nearest version the range allows, such as 3.11 for >=3.10,<3.12.
  • Services, when the repo has no ox.toml: PostgreSQL when psycopg, psycopg2 or asyncpg is a dependency, and Redis when redis is.

Sometimes ox cannot be sure of the start. Then it proposes none, and ox check prints a hint that says why: uvicorn is not a dependency, the app is made inside a function, or the file makes more than one FastAPI object. In that case, set [app] start yourself.

The ox.toml for FastAPI

ox.toml for a FastAPI app with PostgreSQL and Alembic
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 an 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 from uv.lock.
  • migrate: runs your Alembic migrations on every deploy, after ox takes a snapshot of the database.
  • postgres: a database for this app.

Keep the health route fast by not touching the database.

app/main.py
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 = {} to also get REDIS_URL. Once a repo has an ox.toml, ox runs only the services it lists.

DATABASE_URL starts with postgresql://, and SQLAlchemy reads that as the psycopg2 driver. If you use psycopg 3 or asyncpg, put the driver in the URL when you read it, as below. When SQLAlchemy and one of those drivers are dependencies, ox check prints this line for your driver as a hint.

app/db.py
import os
import re
from sqlalchemy import create_engine

url = re.sub(r"^postgres(ql)?://", "postgresql+psycopg://", os.environ["DATABASE_URL"])
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 run ox vars set <project> SECRET_KEY, which asks for the value. ox never reads your .env file.

Some keys in .env.example need your answer: an empty value, a secret, or a placeholder. Each of those must be set before a deploy can start. A key with a real example value keeps the app's default.

Check and deploy the FastAPI app

Check, deploy, and watch
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 logs

For the ox.toml above, ox check ends like this:

ox check, last lines
  Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, PUBLIC_SCHEME, PUBLIC_PORT, DATABASE_URL
  Set on the dashboard before the first deploy: SECRET_KEY
  hint: SQLAlchemy (from uv.lock) reads DATABASE_URL's postgresql:// as psycopg2, which it does not list; in your code, use re.sub(r"^postgres(ql)?://", "postgresql+psycopg://", os.environ["DATABASE_URL"])

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 found no start. The hint above this line says why, such as FastAPI found in main.py, but uvicorn is not a dependency. Add uvicorn to your dependencies, or set [app] start. If there is no hint, your app file is in a place ox does not look.
  • the app exited while starting (…), or GET http://127.0.0.1:<port>/health did not answer within 120s. Read the app's log above it. The usual causes are a wrong module path, such as main:app instead of app.main:app, or a KeyError for a missing variable.
  • migrate: uv run alembic upgrade head: … with fix: 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. Troubleshooting has more causes and fixes, and the config reference lists every key.

Next steps

New to running your own server? How ox runs your apps gives the overview, and the blog's deploy guides walk through a setup by hand.