---
title: "Deploy a FastAPI app"
description: "Deploy a FastAPI app to an exe.dev VM: git push to deploy, uv and systemd, SQLite on a persistent disk, private /docs, and HTTPS from exe.dev."
url: https://exe.dev/deploy/fastapi
category: "Frameworks"
updated: 2026-10-17
---

# Deploy a FastAPI app

Your API on a VM of its own. Push to deploy; the data stays put.

- **Software:** FastAPI (https://fastapi.tiangolo.com)
- **Source:** https://github.com/fastapi/fastapi
- **License:** MIT
- **Time:** 10 minutes
- **Example VM name:** `my-fastapi` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/fastapi/manifest.json

## What you get

- Your app, or a new FastAPI + SQLModel starter, running as `app.service` on port 8000 and enabled to start after a reboot.
- `git push exe main` deploys: the hook builds a new release in `~/releases` (uv, Alembic migrations), switches `~/app` to it, restarts, and checks the app answers. A failed deploy rejects the push and leaves the running version up.
- exe.dev's proxy headers trusted from 127.0.0.1 (the Uvicorn flags in `ExecStart`), so redirects and generated URLs use `https://<vm>.exe.xyz`. For a server without such flags, the owner is told what to change.
- Data in `~/data` (SQLite for the starter), backed up every night to `~/backups`, with a backup restored and checked in a scratch directory.
- The interactive API docs at `https://<vm>.exe.xyz/docs`, private behind exe.dev login.

Running on the VM:

- **Your FastAPI app**: `fastapi run` (Uvicorn) with one worker per CPU, as `app.service` under systemd, port 8000, data in `~/app`
- **Python 3.14 and uv**: uv installs Python and your locked dependencies into `~/app/.venv`
- **SQLite**: SQLModel and Alembic migrations in the starter app, data in `~/data`
- **Deploy hook**: `git push` installs, migrates, restarts, and checks; a failure rejects the push, data in `~/app.git`
- **Nightly backup**: a systemd timer; keeps two weeks, data in `~/backups`

How it is reached:

- https://my-fastapi.exe.xyz/ (private): your API, for you, people you share it with, and scripts with a token
- https://my-fastapi.exe.xyz/docs (private): the interactive API docs
- my-fastapi.exe.xyz:app.git (private): `git push exe main` deploys

## Deploy with Shelley

Open https://exe.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20FastAPI%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Ffastapi.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20FastAPI%20app%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-fastapi.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-fastapi.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Your%20app%2C%20or%20a%20new%20FastAPI%20%2B%20SQLModel%20starter%2C%20running%20as%20%60app.service%60%20on%20port%208000%20and%20enabled%20to%20start%20after%20a%20reboot.%0A-%20%60git%20push%20exe%20main%60%20deploys%3A%20the%20hook%20builds%20a%20new%20release%20in%20%60~%2Freleases%60%20%28uv%2C%20Alembic%20migrations%29%2C%20switches%20%60~%2Fapp%60%20to%20it%2C%20restarts%2C%20and%20checks%20the%20app%20answers.%20A%20failed%20deploy%20rejects%20the%20push%20and%20leaves%20the%20running%20version%20up.%0A-%20exe.dev%27s%20proxy%20headers%20trusted%20from%20127.0.0.1%20%28the%20Uvicorn%20flags%20in%20%60ExecStart%60%29%2C%20so%20redirects%20and%20generated%20URLs%20use%20%60https%3A%2F%2F%3Cvm%3E.exe.xyz%60.%20For%20a%20server%20without%20such%20flags%2C%20the%20owner%20is%20told%20what%20to%20change.%0A-%20Data%20in%20%60~%2Fdata%60%20%28SQLite%20for%20the%20starter%29%2C%20backed%20up%20every%20night%20to%20%60~%2Fbackups%60%2C%20with%20a%20backup%20restored%20and%20checked%20in%20a%20scratch%20directory.%0A-%20The%20interactive%20API%20docs%20at%20%60https%3A%2F%2F%3Cvm%3E.exe.xyz%2Fdocs%60%2C%20private%20behind%20exe.dev%20login.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20follow%20%22Set%20up%0Aby%20hand%22%20in%20order%2C%20skipping%20VM%20creation.%20The%20walkthrough%20above%20it%20is%20for%0Athe%20owner%20afterwards.%20Ask%20for%20REPO%20first%20if%20it%20says%20%3Cfill-this-in%3E.%0AFor%20REPO%3Dnew%2C%20build%20the%20starter%20exactly%20as%20written.%20For%20a%20repository%2C%0Afollow%20%22Bring%20your%20app%22%3A%20adapt%20ExecStart%2C%20~%2Fapp.env%2C%20and%20the%20hook%27s%0Ainstall%20step%20to%20the%20app.%20Never%20commit%20to%20the%20owner%27s%20repository%20or%20push%0Ato%20~%2Fapp.git%20%28run%20the%20hook%20by%20hand%20instead%29%2C%20so%20the%20owner%27s%20first%20push%0Afast-forwards%2C%20and%20keep%20the%20hook%20deploying%20the%20repository%27s%20default%0Abranch.%20Remove%20whatever%20you%20make%20only%20for%20testing.%20If%20a%20private%0Arepository%20can%27t%20be%20cloned%2C%20give%20the%20owner%20the%20%60integrations%20add%20github%60%0Acommand%20with%20this%20VM%27s%20name%20and%20wait%20for%20them.%0ADon%27t%20reboot%2C%20don%27t%20make%20the%20VM%20public%2C%20and%20don%27t%20run%20the%20Restore%20steps%0Aon%20live%20data%3A%20test%20the%20backup%20in%20a%20scratch%20directory%20as%20written.%0AThe%20VM%20can%27t%20open%20its%20own%20https%3A%2F%2F%3Cvm%3E.exe.xyz%3B%20%22Check%20it%22%20tests%20the%0Aproxy%27s%20view%20through%20localhost%20instead.%0AFinish%20with%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2Fdocs%20and%20only%20the%20one%20git%20command%20from%0A%22Deploy%20with%20git%20push%22%20that%20fits%3A%20%60git%20clone%20-o%20exe%60%20for%20REPO%3Dnew%2C%0A%60git%20remote%20add%20exe%60%20for%20a%20repository%2C%20plus%20the%20branch%20to%20push%20if%20it%0Aisn%27t%20main%2C%20and%20anything%20you%20adapted%20or%20that%20the%20owner%20should%20change.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20git%20clone%20-o%20exe%20my-fastapi.exe.xyz%3Aapp.git%20%20%20%20%23%20if%20you%20started%20fresh%0A%20%20git%20remote%20add%20exe%20my-fastapi.exe.xyz%3Aapp.git%20%20%23%20or%2C%20in%20your%20own%20app%27s%20checkout%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/fastapi/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
git clone -o exe my-fastapi.exe.xyz:app.git    # if you started fresh
git remote add exe my-fastapi.exe.xyz:app.git  # or, in your own app's checkout
```

Or follow the steps below by hand.

FastAPI makes the API; this page gives it a home. One exe.dev VM runs your
app under systemd, keeps its SQLite database on a real disk, and deploys
every `git push`, like Heroku did. exe.dev puts it on HTTPS, behind its
own login until you decide otherwise, so your `/docs` page is private from
the start.

Unlike a container platform, nothing sleeps and nothing resets: files you
write are still there tomorrow, there are no cold starts, and you can SSH
in, read the logs, or ask the agent on the VM what went wrong. Bring a
repository from GitHub, or start from a small FastAPI app with SQLite and
migrations already wired up.

## Open your API

When Shelley finishes, open `https://my-fastapi.exe.xyz/docs`. exe.dev asks
you to sign in, then shows FastAPI's interactive docs. Only you can see
them, until you [share the VM](/docs/sharing).

In the starter app, open **POST /notes**, click **Try it out**, and send
`{"text": "hello"}`. The answer's `author` is your email address: exe.dev's
proxy told the app who you are, in the `X-ExeDev-Email` header, so the app
needs no login of its own. **GET /notes** lists what you've saved; it's in
SQLite at `~/data/app.db`.

## Deploy with git push

The VM keeps a Git repository at `~/app.git`. Pushing to it deploys. Add it
as a remote called `exe` on your computer. If you started fresh, clone it:

```bash
git clone -o exe my-fastapi.exe.xyz:app.git my-fastapi
cd my-fastapi
```

If you brought your own repository, add the remote to your checkout
instead:

```bash
git remote add exe my-fastapi.exe.xyz:app.git
```

Change something, commit, and push. In the starter, edit the `hello`
function in `main.py`:

```bash
git commit -am "Say hello differently"
git push exe main
```

(If your repository's branch is `master`, push `master`.)

The deploy runs inside the push, and its output comes back to you:

```text
remote: deploy: 4e1d2c9 (main)
remote: deploy: 4e1d2c9 is live at https://my-fastapi.exe.xyz/
```

The hook builds the commit in a new directory, `~/releases/<time>-<commit>`:
`uv sync --locked`, then, if a migration is coming, a database backup and
`alembic upgrade head`. Then it points `~/app` at the new release,
restarts the app (a second or two without answers), and waits for it to
answer. If anything fails, the push ends with `deploy: FAILED`, the
errors, and `! [remote rejected] main -> main (pre-receive hook declined)`.
The running version stays (or comes back, if the new one didn't answer),
and `main` on the VM stays at the commit that's running. Fix it and push
again. Migrations aren't undone, so make them ones the previous version
can live with: add a column rather than rename one.

To go back to an earlier version, push it:

```bash
git push -f exe 4e1d2c9:main
```

GitHub can stay your source of truth: `git push origin main` to share the
code, `git push exe main` to ship it.

> [!TIP]
> If `ssh my-fastapi.exe.xyz` works but the push can't connect, use
> `vm+my-fastapi@vm.exe.xyz:app.git` as the remote's address
> ([why](/docs/faq/ssh-destination)).

### Work on it locally

You need [uv](https://docs.astral.sh/uv/) on your computer. In your
checkout, `uv sync` installs the same Python and packages the VM uses.
The starter keeps a local database in `app.db`, which Git ignores:

```bash
uv run alembic upgrade head
uv run fastapi dev
```

### Change the database

The starter's tables are SQLModel classes in `models.py`, and Alembic
migrations in `migrations/` change the real database to match. To add a
column, add a field to `Note`, for example
`done: bool = Field(default=False, sa_column_kwargs={"server_default": "0"})`.
The `server_default` fills in the rows that already exist. Then generate
the migration:

```bash
uv run alembic upgrade head
uv run alembic revision --autogenerate -m "add done to note"
```

Read the new file in `migrations/versions/`, then commit it with
`models.py` and push. The hook backs up the database before it migrates.

## Call it from scripts

Curl, cron jobs, and other programs can't sign in with a browser. Give
them a token instead. On your computer, make one for this VM; `--exp`
sets when it stops working:

```bash
ssh exe.dev ssh-key generate-api-key --vm=my-fastapi --label=notes-script --exp=90d
```

Send it in the `X-Exedev-Authorization` header:

```bash
export EXE_TOKEN=exe1.paste-yours-here
curl -H "X-Exedev-Authorization: Bearer $EXE_TOKEN" https://my-fastapi.exe.xyz/notes
```

Or from Python, with [HTTPX](https://www.python-httpx.org/):

```python
import os

import httpx

api = httpx.Client(
    base_url="https://my-fastapi.exe.xyz",
    headers={"X-Exedev-Authorization": f"Bearer {os.environ['EXE_TOKEN']}"},
)
print(api.post("/notes", json={"text": "from a script"}).json())
```

exe.dev checks the token and removes that header before the request
reaches your app, which sees `X-ExeDev-Email` as if you'd signed in. Your
app's own `Authorization` header stays free for its own auth. See
[HTTPS tokens for VMs](/docs/https-tokens-for-vms). Another exe.dev VM can
call the API without a token through a
[VM-to-VM integration](/docs/integrations-vm-to-vm).

## Keep your data

The database and anything else your app writes belong in `~/data`. Every
night a timer copies them, with `~/app.env`, into `~/backups`, and keeps two
weeks of copies; the hook adds one before each migration. A backup on the
same VM won't help if you lose the VM, so copy them off now and then. On
your computer:

```bash
scp -r my-fastapi.exe.xyz:backups my-fastapi-backups
```

They hold your app's secrets, so keep them private. For a copy of the
whole VM, `ssh exe.dev cp my-fastapi my-fastapi-copy` works too. To
restore, see [Back up and restore](#back-up-and-restore).

## Make it public (optional)

On your computer:

```bash
ssh exe.dev share set-public my-fastapi
```

Now anyone can call `https://my-fastapi.exe.xyz/`, including `/docs`.
Visitors get `X-ExeDev-Email` only once they sign in on this host, at
`https://my-fastapi.exe.xyz/__exe.dev/login?redirect=/docs`. To keep the
docs and some endpoints for yourself, check that header in a dependency
that sends visitors without it to that address. A sketch for `main.py`,
with your address in `ADMIN_EMAILS=you@example.com` in `~/app.env`:

```python
import os

from fastapi import HTTPException
from fastapi.openapi.docs import get_swagger_ui_html

ADMINS = set(filter(None, os.environ.get("ADMIN_EMAILS", "").split(",")))


def admin(request: Request) -> str:
    email = request.headers.get("X-ExeDev-Email")
    if not email:  # not signed in: exe.dev's login sends them back here
        login = f"/__exe.dev/login?redirect={request.url.path}"
        raise HTTPException(307, headers={"Location": login})
    if email not in ADMINS:
        raise HTTPException(403)
    return email


Admin = Annotated[str, Depends(admin)]
app = FastAPI(title="Notes", docs_url=None, redoc_url=None, openapi_url=None)


@app.get("/openapi.json", include_in_schema=False)
def openapi(_: Admin) -> dict:
    return app.openapi()


@app.get("/docs", include_in_schema=False)
def docs(_: Admin):
    return get_swagger_ui_html(openapi_url="/openapi.json", title=app.title)
```

Add `_: Admin` to any endpoint that should be yours alone, such as
`add_note`. `ssh exe.dev share set-private my-fastapi` closes the door
again.

For your own domain, point it at the VM as in
[custom domains](/docs/cnames). FastAPI doesn't check the `Host` header
unless you add `TrustedHostMiddleware`, so there's nothing to change in
the app.

## Background work

`BackgroundTasks` runs a function after the response has gone out, in the
same worker process. It's right for small things, like sending a
notification. But it has no retries, and a deploy restarts the process, so
anything slow or important belongs in a separate worker:
`app-worker.service`, which the deploy hook restarts after the app.
[Huey](https://huey.readthedocs.io/) keeps its queue in SQLite, so you
don't need Redis. Add it with `uv add huey`, and create `worker.py`:

```python
import os

from huey import SqliteHuey, crontab

huey = SqliteHuey(filename=os.environ.get("HUEY_DB", "huey.db"))


@huey.task(retries=3, retry_delay=30)
def summarize(note_id: int) -> None:
    ...  # slow work: call an API, render a PDF, send an email


@huey.periodic_task(crontab(minute="0", hour="4"))
def nightly_cleanup() -> None:
    ...
```

Calling `summarize(row.id)` in an endpoint queues the job and returns at
once. Before you push, tell the VM where the queue lives and create the
worker's service. On the VM:

```bash
echo "HUEY_DB=$HOME/data/huey.db" >> ~/app.env
sudo tee /etc/systemd/system/app-worker.service >/dev/null <<'EOF'
[Unit]
Description=Background worker for ~/app
After=network-online.target
Wants=network-online.target

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/exedev/app/.venv/bin/huey_consumer worker.huey --workers 2
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app-worker.service
```

Then commit `worker.py` and the `uv.lock` change, and push. The deploy
restarts the app, which now sees `HUEY_DB`, and starts the worker;
`journalctl -u app-worker -f` shows the jobs as they run. Celery, RQ, or
arq work the same way with a different `ExecStart`, plus a Redis server
(`sudo apt-get install -y redis-server`).

## Call an LLM

Many FastAPI apps are model backends. exe.dev's
[LLM integration](/docs/integrations-llm) answers at
`https://llm.int.exe.xyz` from your VM, with no API key: the VM is the
credential, and usage is billed to your exe.dev account. Add the OpenAI
SDK with `uv add openai`, then stream an answer with FastAPI's
server-sent events:

```python
from fastapi.sse import EventSourceResponse
from openai import AsyncOpenAI
from pydantic import BaseModel

llm = AsyncOpenAI(base_url="https://llm.int.exe.xyz/v1", api_key="unused")


class Question(BaseModel):
    text: str


@app.post("/ask", response_class=EventSourceResponse)
async def ask(question: Question):
    stream = await llm.chat.completions.create(
        model="gpt-5.4-mini",
        messages=[{"role": "user", "content": question.text}],
        stream=True,
    )
    async for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            yield chunk.choices[0].delta.content
```

`curl -s https://llm.int.exe.xyz/v1/models` on the VM lists the models
you can use; Anthropic's SDK works the same way with
`base_url="https://llm.int.exe.xyz"`. This only works on the VM, so
locally, point `base_url` at a provider and use your own key.

## Ask Shelley

Shelley, the agent on your VM, is at `https://my-fastapi.shelley.exe.xyz/`.
It can read `journalctl -u app`, the deploy hook, and your code, so "my
last push failed, why?" is a fine question. It can change the app too:
ask it to work in a clone of `~/app.git` and deploy with `git push`, like
you do. Then `git pull exe main` brings its commits to your computer.

## The nuts and bolts

Happy with what Shelley set up? You can stop here. These are the steps it
followed, and the maintenance commands for later.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Set up by hand</summary>

### Create the VM

On your computer (pick another name if `my-fastapi` is taken):

```bash
ssh exe.dev new --name=my-fastapi
ssh my-fastapi.exe.xyz
```

Everything else runs **on the VM**, as `exedev`.

### Prepare the VM

uv is preinstalled. Install Python 3.14 with it, and make the directories
for data, backups, and scripts. Then create `~/app.env`, the app's
settings and secrets, once; systemd and the deploy hook both read it:

```bash
uv python install 3.14
mkdir -p ~/data ~/backups ~/bin
if [ ! -e ~/app.env ]; then
  install -m 600 /dev/null ~/app.env
  cat >> ~/app.env <<EOF
# Settings and secrets for app.service and the deploy hook.
# One KEY=value per line, no quotes. Back this file up.
DATABASE_URL=sqlite:///$HOME/data/app.db
WEB_CONCURRENCY=$(nproc)
PORT=8000
EOF
fi
cat ~/app.env
```

`WEB_CONCURRENCY` is the number of Uvicorn worker processes: one per vCPU.
`PORT` is for start commands that read it, as on Heroku and Railway;
exe.dev sends your app's traffic to port 8000.

### Back up nightly

This script copies each SQLite database in `~/data` with `sqlite3
.backup`, which is safe while the app writes, and checks the copy. It
adds the rest of `~/data` and `~/app.env`, and keeps the 14 newest backups
of each kind:

```bash
cat > ~/bin/app-backup <<'EOF'
#!/bin/bash
# Back up ~/data and ~/app.env to ~/backups/app-<kind>-<time>.tar.gz.
# SQLite databases (*.db) are copied with .backup, safe while the app runs.
# Keeps the 14 newest backups of each kind. Usage: app-backup [nightly|deploy|manual]
set -euo pipefail
umask 077
kind=${1:-manual}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$HOME/backups" "$work/data"
rsync -a --exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' "$HOME/data/" "$work/data/"
for db in "$HOME"/data/*.db; do
  [ -e "$db" ] || continue
  copy="$work/data/$(basename "$db")"
  sqlite3 "$db" ".backup '$copy'"
  [ "$(sqlite3 "$copy" 'PRAGMA integrity_check')" = ok ]
done
cp "$HOME/app.env" "$work/"
out="$HOME/backups/app-$kind-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$out.part" -C "$work" .
mv "$out.part" "$out"
ls -1t "$HOME/backups/app-$kind-"*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/bin/app-backup
```

Run it every night at about 03:30 UTC:

```bash
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up ~/data and ~/app.env to ~/backups

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/bin/app-backup nightly
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the app every night

[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer
systemctl list-timers app-backup.timer
```

The timer should show its next run.

### Run the app with systemd

`fastapi run` is FastAPI's production server: Uvicorn, without reloading,
with `--workers` processes. exe.dev's [HTTPS proxy](/docs/proxy) reaches
the app from 127.0.0.1 and sends `X-Forwarded-Proto: https` and
`X-Forwarded-For`. Trusting those headers from 127.0.0.1, and only from
there, makes FastAPI's redirects and `url_for` use `https://`, and gives
you the visitor's address in `request.client`. That's Uvicorn's default;
the flags below make it explicit:

```bash
sudo tee /etc/systemd/system/app.service >/dev/null <<'EOF'
[Unit]
Description=FastAPI app in ~/app
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=PYTHONUNBUFFERED=1
# exe.dev's proxy connects from 127.0.0.1: trust its X-Forwarded-* headers.
ExecStart=/home/exedev/app/.venv/bin/fastapi run --port 8000 --workers ${WEB_CONCURRENCY} --proxy-headers --forwarded-allow-ips 127.0.0.1
Restart=always
RestartSec=3
# Don't wait forever for open streams when restarting.
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app.service
```

It starts with the first deploy. With no path, `fastapi run` finds the app
through `[tool.fastapi] entrypoint` in `pyproject.toml`, or in `main.py`,
`app.py`, `api.py`, or `app/main.py`.

> [!WARNING]
> FastAPI's docs suggest `--forwarded-allow-ips="*"`. Don't use it here:
> exe.dev adds the visitor's address to any `X-Forwarded-For` the visitor
> sent, and with `*` Uvicorn believes the first, made-up entry.

### Add the deploy hook

Create the repository you'll push to. To start fresh:

```bash
git init --bare -b main ~/app.git
```

To bring your app, clone it from GitHub instead, with your repository in
place of `OWNER/NAME`:

```bash
git clone --bare https://github.com/OWNER/NAME.git ~/app.git
```

If that asks for a password, the repository is private. On your
computer, connect it with the [GitHub integration](/docs/integrations-github),
then clone through the integration, with no token on the VM:

```bash
ssh exe.dev integrations add github --name NAME --repository OWNER/NAME --attach vm:my-fastapi
```

```bash
git clone --bare https://github.int.exe.xyz/OWNER/NAME.git ~/app.git
```

The branch `HEAD` points to (`main`, usually) is the one that deploys;
pushes to other branches are stored but not deployed. Now the hook:

```bash
cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/bin/bash
# Deploys each push to the deploy branch before git accepts it, or the
# branch as it is when run by hand. Builds it in a new
# ~/releases/<time>-<commit>: installs dependencies with uv, backs up the
# database and runs Alembic migrations. Then points ~/app at the new
# release, restarts app.service, and waits for an answer. Any failure
# rejects the push, and ~/app stays on (or goes back to) the running release.
set -Eeuo pipefail
by_git=${GIT_DIR:+yes}   # git sets GIT_DIR for hooks, so this is a push
export GIT_DIR=$HOME/app.git PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin
branch=$(git symbolic-ref --short HEAD)
exec 9>"$GIT_DIR/deploy.lock" && flock 9   # one deploy at a time

new=$(git rev-parse -q --verify "refs/heads/$branch" || true)
if [ -n "$by_git" ]; then
  now=${new:-0000000000000000000000000000000000000000} new=
  while read -r was sha ref; do
    if [ "$ref" = "refs/heads/$branch" ]; then new=$sha from=$was; fi
  done
  if [ -z "$new" ]; then echo "deploy: only pushes to $branch deploy"; exit 0; fi
  if [[ $new =~ ^0+$ ]]; then echo "deploy: $branch can't be deleted"; exit 1; fi
  if [ "$from" != "$now" ]; then echo "deploy: $branch moved; pull and push again"; exit 1; fi
fi
old=$(readlink "$HOME/app" || true)   # the live release
release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${new:0:12}
echo "deploy: ${new:0:7} ($branch)"
mkdir -p "$HOME/releases" && mkdir "$release"
trap 'rm -rf "$release"; echo "deploy: FAILED; the live version is unchanged" >&2' ERR
git archive "$new" | tar -x -C "$release"
unset GIT_DIR GIT_QUARANTINE_PATH GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
cd "$release"
if [ -f uv.lock ]; then uv sync -q --locked --no-dev
elif [ -f pyproject.toml ]; then uv sync -q --no-dev
else uv venv -q && uv pip install -q -r requirements.txt
fi
set -a; . "$HOME/app.env"; set +a
if [ -f alembic.ini ] || grep -qs '^\[tool\.alembic\]' pyproject.toml; then
  current=$(.venv/bin/alembic current 2>&1 || true)
  if [[ $current == *"Can't locate revision"* ]]; then
    echo "deploy: the database is newer than this commit; not migrating"
  elif [[ $current != *"(head)"* ]]; then
    "$HOME/bin/app-backup" deploy   # a migration is coming: back up first
    .venv/bin/alembic upgrade head
  fi
fi
trap - ERR

go_live() {   # point ~/app at a release, restart, wait 30 s for an answer below 500
  ln -sfn "$1" "$HOME/app.next" && mv -T "$HOME/app.next" "$HOME/app"
  since=$(date +%s)
  sudo systemctl restart app.service
  for _ in $(seq 30); do
    sleep 1
    code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/) || true
    [ "$code" -ge 200 ] && [ "$code" -lt 500 ] && return 0
  done
  return 1
}
if ! go_live "$release"; then
  echo "deploy: no answer on port 8000. Errors in journalctl -u app:" >&2
  journalctl -u app.service --since "@$since" -o cat |
    grep -E '^[A-Za-z_.]+(Error|Exception)\b' | sort | uniq -c | tail -n 5 >&2 || true
  if [ -z "$old" ]; then echo "deploy: FAILED; fix it and deploy again" >&2; exit 1; fi
  go_live "$old" || true
  rm -rf "$release"
  echo "deploy: FAILED; back to ${old: -12:7}" >&2
  exit 1
fi
if systemctl -q is-enabled app-worker.service 2>/dev/null; then
  sudo systemctl restart app-worker.service
fi
ls -1d "$HOME"/releases/*/ | head -n -3 | xargs -r rm -rf   # keep three
echo "deploy: ${new:0:7} is live at https://$(hostname).exe.xyz/"
EOF
chmod +x ~/app.git/hooks/pre-receive
```

`~/app` is a link to the live release: `readlink ~/app` shows which, and
the hook keeps the three newest in `~/releases`. Now follow
[Start fresh](#start-fresh) or [Bring your app](#bring-your-app).

### Start fresh

The starter is a small notes API: FastAPI, SQLModel tables in SQLite, and
Alembic migrations, created with uv the way FastAPI's docs recommend. Make
the project in a scratch directory and add the packages:

```bash
rm -rf /tmp/starter && mkdir /tmp/starter && cd /tmp/starter
git init -q -b main
uv init --no-package --no-readme --python 3.14 --name "$(hostname)"
uv add "fastapi[standard]" sqlmodel alembic
uv run alembic init migrations
sed -i 's/^import sqlalchemy as sa$/&\nimport sqlmodel/' migrations/script.py.mako
printf '\n[tool.fastapi]\nentrypoint = "main:app"\n' >> pyproject.toml
```

The `sed` line lets Alembic's migrations use SQLModel's column types. The
tables, and the database connection, go in `models.py`:

```bash
cat > models.py <<'EOF'
"""The database: SQLite in ~/data on the VM (DATABASE_URL), app.db locally."""

import os
from datetime import UTC, datetime

from sqlalchemy import event
from sqlmodel import Field, SQLModel, create_engine

engine = create_engine(os.environ.get("DATABASE_URL", "sqlite:///app.db"))

if engine.dialect.name == "sqlite":

    @event.listens_for(engine, "connect")
    def _sqlite_settings(conn, _):
        # WAL lets requests read while another writes; busy_timeout makes
        # writers in different workers wait their turn instead of failing.
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA busy_timeout=5000")


class NoteIn(SQLModel):
    text: str = Field(min_length=1, max_length=2000)


class Note(NoteIn, table=True):
    id: int | None = Field(default=None, primary_key=True)
    author: str | None = None
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
EOF
```

The API goes in `main.py`. `get_user` reads the email exe.dev's proxy adds
for signed-in visitors:

```bash
cat > main.py <<'EOF'
"""A small FastAPI app: notes in SQLite, signed in with exe.dev."""

from typing import Annotated

from fastapi import Depends, FastAPI, Request
from sqlmodel import Session, select

from models import Note, NoteIn, engine

app = FastAPI(title="Notes")


def get_session():
    with Session(engine) as session:
        yield session


def get_user(request: Request) -> str | None:
    # exe.dev's proxy adds this header for signed-in visitors.
    return request.headers.get("X-ExeDev-Email")


SessionDep = Annotated[Session, Depends(get_session)]
User = Annotated[str | None, Depends(get_user)]


@app.get("/")
def hello(user: User) -> dict:
    return {"hello": user or "stranger", "docs": "/docs"}


@app.get("/notes")
def list_notes(session: SessionDep) -> list[Note]:
    return session.exec(select(Note).order_by(Note.id.desc()).limit(100)).all()


@app.post("/notes", status_code=201)
def add_note(note: NoteIn, session: SessionDep, user: User) -> Note:
    row = Note.model_validate(note, update={"author": user})
    session.add(row)
    session.commit()
    session.refresh(row)
    return row


@app.get("/health")
def health(session: SessionDep) -> dict:
    session.exec(select(1)).one()
    return {"ok": True}
EOF
```

Alembic's generated `env.py` expects a URL in `alembic.ini`; this one uses
the app's own engine and tables instead, and SQLite-friendly "batch"
migrations:

```bash
cat > migrations/env.py <<'EOF'
"""Alembic migrations, run with the app's own engine and models."""

from logging.config import fileConfig

from alembic import context
from sqlmodel import SQLModel

import models

if context.config.config_file_name:
    fileConfig(context.config.config_file_name)

with models.engine.connect() as connection:
    context.configure(
        connection=connection,
        target_metadata=SQLModel.metadata,
        render_as_batch=True,  # SQLite can't ALTER most columns; copy the table instead
    )
    with context.begin_transaction():
        context.run_migrations()
EOF
```

Generate the first migration against an empty scratch database, and
commit:

```bash
uv run alembic revision --autogenerate -m "create note table"
rm -f app.db app.db-shm app.db-wal
cat > .gitignore <<'EOF'
__pycache__/
*.py[oc]
.venv/
.env
*.db
*.db-shm
*.db-wal
EOF
git add .gitignore .python-version pyproject.toml uv.lock main.py models.py alembic.ini migrations
git -c user.name="$(hostname)" -c user.email="exedev@$(hostname).exe.xyz" commit -q -m "FastAPI starter"
```

Push it to `~/app.git`. That runs the hook: the first deploy.

```bash
git push ~/app.git main && cd ~ && rm -rf /tmp/starter
```

Expect `deploy: ... is live at https://...` at the end. Continue with
[Check it](#check-it).

### Bring your app

The hook already knows uv projects (`uv.lock` or `pyproject.toml`) and
`requirements.txt`, and runs Alembic when `alembic.ini` is at the top of
the repository. Check what else your app expects. Look at its README,
`Procfile`, `Dockerfile`, or `.env.example`:

- **How it starts.** If `fastapi run` can't find the app by itself, name
  it in `ExecStart` in `/etc/systemd/system/app.service`, for example
  `fastapi run app/main.py` or `fastapi run --entrypoint backend.main:app`.
  Without `fastapi[standard]` (or `fastapi-cli`) in its dependencies,
  there's no `fastapi` command: use the app's own start command, from its
  `Procfile`, `railway.json`, or `Dockerfile`, with `.venv/bin/` in front.
  For Uvicorn, add
  `--workers ${WEB_CONCURRENCY} --proxy-headers --forwarded-allow-ips 127.0.0.1`.
  Other servers, such as Hypercorn, ignore `X-Forwarded-*` until the app
  sets that up in code; adding `uvicorn` to the app's dependencies is the
  easy way out. Then run `sudo systemctl daemon-reload`.
- **Settings.** Add the variables it reads to `~/app.env`. Make secrets
  with `openssl rand -hex 32`.
- **Files.** Point SQLite databases and uploads into `~/data`, such as
  `DATABASE_URL=sqlite:////home/exedev/data/app.db`. Anything written
  inside `~/app` is lost at the next deploy. If the app writes to a fixed
  relative path, such as `sqlite:///./app.db`, run it from `~/data`:
  set `WorkingDirectory=/home/exedev/data` in the service. If the app
  isn't an installed package, tell the server where the code is:
  `fastapi run /home/exedev/app/main.py`, or Uvicorn's
  `--app-dir /home/exedev/app`.
- **Python.** uv uses the version in `.python-version` or
  `requires-python`. A project with only `requirements.txt` gets the
  newest installed Python. For another, such as the one in a Heroku
  `runtime.txt`, change `uv venv` in the hook to `uv venv --python 3.12`.
- **Somewhere else.** If `pyproject.toml` isn't at the top of the
  repository, add that directory to the hook's `cd "$release"` and to the
  service's `WorkingDirectory` and `ExecStart`. Other release steps go in
  the hook, before `trap - ERR`.

Then deploy it once:

```bash
~/app.git/hooks/pre-receive
```

Expect `deploy: ... is live at https://...`. If it fails, `journalctl -u app
-n 50` usually says why: a missing setting, or a module that isn't in the
dependencies. Fix the setting, or the hook or service, and run it again.

### Check it

```bash
systemctl is-active app.service
systemctl is-enabled app.service app-backup.timer
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/docs
curl -s -o /dev/null -w '%{redirect_url}\n' -H "Host: $(hostname).exe.xyz" \
  -H 'X-Forwarded-Proto: https' http://localhost:8000/docs/
```

Expect `active`, `enabled` twice, `200`, and
`https://<vm>.exe.xyz/docs`: FastAPI redirected `/docs/` to `/docs` and
built the address from the proxy's headers. If it shows `http://`, check
the flags in `ExecStart`; servers other than Uvicorn need their own
setting (see [Bring your app](#bring-your-app)). In the starter, `curl -s
localhost:8000/health` says `{"ok":true}`. `sudo reboot`, wait a minute,
reconnect, and the same checks pass.

### Test a backup

Make a backup and restore it into a scratch directory:

```bash
sudo systemctl start app-backup.service
latest=$(ls -1t ~/backups/app-nightly-*.tar.gz | head -n 1)
rm -rf /tmp/restore-test && mkdir /tmp/restore-test
tar -xzf "$latest" -C /tmp/restore-test
ls -la /tmp/restore-test /tmp/restore-test/data
for db in /tmp/restore-test/data/*.db; do
  [ -e "$db" ] && sqlite3 "$db" 'PRAGMA integrity_check' && sqlite3 "$db" .tables
done
rm -rf /tmp/restore-test
```

Expect `app.env`, the `data` directory, and for each database `ok` and its
tables (the starter's are `alembic_version` and `note`). Setup is done:
continue with [Open your API](#open-your-api).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

On the VM, `~/bin/app-backup` makes a backup now and prints its path. Each
archive holds `data/` (each SQLite database as a consistent copy) and
`app.env`.

### Restore

Pick a backup in `~/backups` and use its name below. On a new VM, first
follow [Set up by hand](#create-the-vm) up to your first deploy, then copy
the archive over from your computer:

```bash
scp my-fastapi-backups/app-nightly-20261017-033012.tar.gz my-fastapi.exe.xyz:backups/
```

On the VM, stop the app, set the current data aside, and unpack:

```bash
backup=~/backups/app-nightly-20261017-033012.tar.gz
sudo systemctl stop app.service app-worker.service 2>/dev/null || true
mv ~/data ~/data.before-restore-$(date +%s)
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf "$backup" -C /tmp/restore
mv /tmp/restore/data ~/data
diff /tmp/restore/app.env ~/app.env
```

If `diff` shows settings or secrets you need from the backup (on a new VM,
you do), copy them: `install -m 600 /tmp/restore/app.env ~/app.env`, then
set `WEB_CONCURRENCY` to this VM's `nproc`. Then bring the schema up to
the code's and start again:

```bash
cd ~/app && set -a && . ~/app.env && set +a
[ -f alembic.ini ] && .venv/bin/alembic upgrade head
sudo systemctl start app.service
systemctl -q is-enabled app-worker.service 2>/dev/null && sudo systemctl start app-worker.service
sleep 5; curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/docs
rm -rf /tmp/restore
```

Expect `200`, and your data in the app. Delete `~/data.before-restore-*`
once you're happy.

</details>

<details id="update" class="dp-details">
<summary>Update Python, FastAPI, and the rest</summary>

Updates travel through Git like any other change. On your computer:

- **Packages:** `uv lock --upgrade` (or `--upgrade-package fastapi`), run
  your tests, commit `uv.lock`, and push. Read FastAPI's
  [release notes](https://fastapi.tiangolo.com/release-notes/) first:
  it's pre-1.0, so minor versions can change behavior.
- **Python:** change `.python-version` (to `3.15`, say) and
  `requires-python` in `pyproject.toml`, run `uv lock`, commit, and push.
  The hook's `uv sync` downloads that Python for the new release.

On the VM:

- **uv:** the preinstalled uv is fine for this setup. For a newer one,
  `curl -LsSf https://astral.sh/uv/install.sh | sh` installs it in
  `~/.local/bin`, which the hook and your shell try first.
- **Ubuntu:** `sudo apt-get update && sudo apt-get upgrade -y`.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM, the app's log, and the live release:

```bash
journalctl -u app -n 100 --no-pager
systemctl status app --no-pager
readlink ~/app
```

- **`deploy: FAILED` and a rejected push:** the lines above say why.
  `The lockfile at uv.lock needs to be updated`: run `uv lock` and commit
  `uv.lock`. A `ModuleNotFoundError` after the restart: the package is
  missing from `pyproject.toml`. An Alembic error: fix the migration;
  the database was backed up first, in `~/backups/app-deploy-*`.
- **Deploy again without a new commit**, after fixing a setting, say: on
  your computer, `ssh my-fastapi.exe.xyz app.git/hooks/pre-receive`.
  Settings alone need only `sudo systemctl restart app` on the VM.
- **The push is rejected (`non-fast-forward`):** someone else pushed
  first, maybe Shelley. `git pull exe main`, then push.
- **Redirects or `url_for` links use `http://`:** the app doesn't trust
  the proxy. Check `--proxy-headers --forwarded-allow-ips
  127.0.0.1` in `ExecStart`.
- **The browser can't reach the app:** check that it listens on port
  8000 (`ss -ltnp | grep 8000`). For another port, run
  `ssh exe.dev share port my-fastapi <port>` on your computer.
- **`database is locked`:** a write took longer than the 5-second
  `busy_timeout`. Keep transactions short, or move to PostgreSQL.
- **Scripts get `401`:** check the `X-Exedev-Authorization: Bearer`
  header, that the token was made with `--vm=my-fastapi`, and that it
  hasn't expired.
- **Down after a reboot:** `sudo systemctl enable --now app.service`.

</details>

## Frequently asked questions

### How many workers should I run?

One per vCPU is a good start, which is what `WEB_CONCURRENCY=$(nproc)` in `~/app.env` gives you: 2 on a default VM. Each worker is a separate process with its own memory, and FastAPI already runs plain `def` endpoints in a thread pool, so more workers only help with CPU-bound work. After a resize (`ssh exe.dev resize my-fastapi --cpu=4`), change the number and run `sudo systemctl restart app`.

### Do I need Gunicorn?

No. `fastapi run --workers` is Uvicorn's own process manager, which restarts a worker that dies, and systemd restarts the whole thing. If your app already runs under Gunicorn, keep it: use the [uvicorn-worker](https://github.com/Kludex/uvicorn-worker) package (`-k uvicorn_worker.UvicornWorker`, `-w ${WEB_CONCURRENCY}`, `-b 0.0.0.0:8000`) in `ExecStart`; Gunicorn trusts proxy headers from 127.0.0.1 by default too.

### Can I use PostgreSQL instead of SQLite?

Yes. Set up [PostgreSQL](/deploy/postgres#run-it-next-to-your-app) on the same VM; it adds a `DATABASE_URL` to `~/app.env`. SQLAlchemy and SQLModel want it to start with `postgresql+psycopg://`, so change that prefix, and `uv add "psycopg[binary]"`. The Postgres page has its own backups. SQLite is the simpler choice until many processes write at once: it's a file on the VM's disk, backed up by the timer above.

### What about FastAPI's full-stack template?

The [Full Stack FastAPI Template](https://github.com/fastapi/full-stack-fastapi-template) is a Docker Compose project with PostgreSQL, a React frontend, and Traefik. Docker and Compose are preinstalled, so [Docker Compose](/deploy/docker-compose) can run it; skip Traefik, since exe.dev already does HTTPS. This page is for the simpler case: one Python app, no containers.

### Does it sleep, or have cold starts?

No. The VM is always on, so the first request after a quiet night is as fast as any other, and BackgroundTasks, schedules, and long streaming responses run to completion. You pay for the VM, not per request; see [exe.dev plans](https://exe.dev/pricing). FastAPI itself is MIT-licensed.

### Can a frontend on another domain call my API?

Make it public first: exe.dev's login works for pages on the VM's own address, not for another site's JavaScript. Then add FastAPI's [CORSMiddleware](https://fastapi.tiangolo.com/tutorial/cors/) with your frontend's origin, and protect what needs it in the app. Or serve the frontend from the same app with `StaticFiles`, and keep it private.


## Related

- [Deploy a Django app](https://exe.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Self-host PostgreSQL](https://exe.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Deploy Next.js on your own server](https://exe.dev/deploy/nextjs.md): Your Next.js app on a server of its own. git push to deploy.
- [Run Claude Code on a remote server](https://exe.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Deploy a Docker Compose app from GitHub](https://exe.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
- [Deploy a Laravel app](https://exe.dev/deploy/laravel.md): Your Laravel app on a server of its own. git push to deploy.
