# 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. 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. 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. It proposes: - The install step from your lockfile: `uv sync --frozen --no-dev` for `uv.lock`, `poetry install --only main --no-root` for `poetry.lock`, `uv venv --allow-existing && uv pip install -r requirements.txt` for `requirements.txt`, or `uv sync --no-dev` for a `pyproject.toml` alone. - The Python version from `.python-version`, `.tool-versions` or `mise.toml`; with none of them, ox's default (`ox check` prints it) for `requirements.txt` and `poetry.lock`, and for `uv.lock` or a `pyproject.toml` alone when its `requires-python` allows that default (otherwise the nearest Python it allows, such as 3.11 for `>=3.10,<3.12`). A `uv` project with no pin used to run the server's own `python3`; it moves to ox's default at its next deploy, and a `.python-version` keeps any other version. - A 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` that 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. - When the repo has no ox.toml: PostgreSQL when `psycopg`, `psycopg2` or `asyncpg` is a dependency, and Redis when `redis` is. 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. Then set `[app] start` yourself. ## The ox.toml for FastAPI ox.toml for a FastAPI app with PostgreSQL and Alembic: ```toml 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 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. The health route should answer without touching the database, so it stays fast. app/main.py: ```python 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]](https://console.t70.sorv.dev/docs/config#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 `postgresql://`, which SQLAlchemy reads as the psycopg2 driver. With psycopg 3 or asyncpg, put the driver in the name when you read it. When SQLAlchemy is a dependency on one of them, `ox check` prints this line for your driver as a hint. app/db.py: ```python 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 with `ox vars set SECRET_KEY`, which asks for the value. Every key in `.env.example` that needs your answer (empty, a secret, or a placeholder) must be set before a deploy can start; a key with a real example value keeps the app's default. ox never reads your `.env` file. ## Check and deploy the FastAPI app Check, deploy, and watch: ```sh ox check # in the repo: prints the plan and "Ready to deploy." ox deploy --wait # stream the deploy, exit with its result ox logs --follow # the app's own logs ``` For the ox.toml above, `ox check` ends like this: ox check, last lines: ```text 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 check` says this when ox found no start. The hint above it says why, such as `FastAPI found in main.py, but uvicorn is not a dependency`. Add `uvicorn` to your dependencies, or set `[app] start`. With no hint, your app file is somewhere ox does not look. - `the app exited while starting (…)`, or `GET http://127.0.0.1:/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. More causes and fixes are in [Troubleshooting](https://console.t70.sorv.dev/docs/troubleshooting), and every key is in the [config reference](https://console.t70.sorv.dev/docs/config). ## Next steps - [Set SECRET_KEY and other variables](https://console.t70.sorv.dev/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://console.t70.sorv.dev/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://console.t70.sorv.dev/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://console.t70.sorv.dev/docs/operate/rollback) to a kept release without a rebuild. - [See the daily PostgreSQL backups](https://console.t70.sorv.dev/docs/operate/backups) and restore one. Last updated 2026-10-08. The page as HTML: https://console.t70.sorv.dev/docs/guides/fastapi