Deploy a FastAPI app
Your API on a VM of its own. Push to deploy; the data stays put.
Shelley handles the setup. About 10 minutes.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/fastapi/prompt.txt | ssh exe.dev new --prompt=/dev/stdinFastAPI 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.
What you get#
- Your app, or a new FastAPI + SQLModel starter, running as
app.serviceon port 8000 and enabled to start after a reboot. git push exe maindeploys: the hook builds a new release in~/releases(uv, Alembic migrations), switches~/appto 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 usehttps://<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.
What's running, and where
https://my-fastapi.exe.xyz/
your API, for you, people you share it with, and scripts with a token
https://my-fastapi.exe.xyz/docs
the interactive API docs
my-fastapi.exe.xyz:app.git
git push exe main deploys
fastapi run (Uvicorn) with one worker per CPU, as app.service under systemd
~/app/.venv
git push installs, migrates, restarts, and checks; a failure rejects the push
Deploy with Shelley#
Click Deploy and Shelley will set up FastAPI on a new VM, then check that it works. No config-file wrangling required.
Have these ready; Shelley asks for them:
REPO: the GitHub repository to deploy, as owner/name or a URL, ornewto start a fresh FastAPI app
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/fastapi/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhen it's done, run this on your computer (only you can):
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 checkoutWhat Shelley is told
REPO=<fill-this-in>
Set up FastAPI on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/fastapi.md from top to bottom.
The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh FastAPI app
Ask me for any that still say <fill-this-in> before you need them.
The recipe's example VM name is my-fastapi. This VM's name is the output of `hostname`: use it wherever the recipe writes my-fastapi. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- 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.
Read the whole recipe, including the closed details, and follow "Set up
by hand" in order, skipping VM creation. The walkthrough above it is for
the owner afterwards. Ask for REPO first if it says <fill-this-in>.
For REPO=new, build the starter exactly as written. For a repository,
follow "Bring your app": adapt ExecStart, ~/app.env, and the hook's
install step to the app. Never commit to the owner's repository or push
to ~/app.git (run the hook by hand instead), so the owner's first push
fast-forwards, and keep the hook deploying the repository's default
branch. Remove whatever you make only for testing. If a private
repository can't be cloned, give the owner the `integrations add github`
command with this VM's name and wait for them.
Don't reboot, don't make the VM public, and don't run the Restore steps
on live data: test the backup in a scratch directory as written.
The VM can't open its own https://<vm>.exe.xyz; "Check it" tests the
proxy's view through localhost instead.
Finish with https://<vm>.exe.xyz/docs and only the one git command from
"Deploy with git push" that fits: `git clone -o exe` for REPO=new,
`git remote add exe` for a repository, plus the branch to push if it
isn't main, and anything you adapted or that the owner should change.
Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
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
Also at /deploy/fastapi/prompt.txt. The recipe it follows is /deploy/fastapi.md.
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.
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:
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:
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:
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:
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:
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.xyzworks but the push can't connect, usevm+my-fastapi@vm.exe.xyz:app.gitas the remote's address (why).
Work on it locally#
You need 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:
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:
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:
ssh exe.dev ssh-key generate-api-key --vm=my-fastapi --label=notes-script --exp=90d
Send it in the X-Exedev-Authorization header:
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:
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. Another exe.dev VM can
call the API without a token through a
VM-to-VM integration.
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:
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.
Make it public (optional)#
On your computer:
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:
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. 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 keeps its queue in SQLite, so you
don't need Redis. Add it with uv add huey, and create worker.py:
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:
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 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:
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.
Set up by hand
Create the VM#
On your computer (pick another name if my-fastapi is taken):
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:
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:
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:
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 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:
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 anyX-Forwarded-Forthe 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:
git init --bare -b main ~/app.git
To bring your app, clone it from GitHub instead, with your repository in
place of OWNER/NAME:
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, then clone through the integration, with no token on the VM:
ssh exe.dev integrations add github --name NAME --repository OWNER/NAME --attach vm:my-fastapi
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:
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 or 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:
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:
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:
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:
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:
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.
git push ~/app.git main && cd ~ && rm -rf /tmp/starter
Expect deploy: ... is live at https://... at the end. Continue with
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 runcan't find the app by itself, name it inExecStartin/etc/systemd/system/app.service, for examplefastapi run app/main.pyorfastapi run --entrypoint backend.main:app. Withoutfastapi[standard](orfastapi-cli) in its dependencies, there's nofastapicommand: use the app's own start command, from itsProcfile,railway.json, orDockerfile, 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, ignoreX-Forwarded-*until the app sets that up in code; addinguvicornto the app's dependencies is the easy way out. Then runsudo systemctl daemon-reload. - Settings. Add the variables it reads to
~/app.env. Make secrets withopenssl rand -hex 32. - Files. Point SQLite databases and uploads into
~/data, such asDATABASE_URL=sqlite:////home/exedev/data/app.db. Anything written inside~/appis lost at the next deploy. If the app writes to a fixed relative path, such assqlite:///./app.db, run it from~/data: setWorkingDirectory=/home/exedev/datain 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-versionorrequires-python. A project with onlyrequirements.txtgets the newest installed Python. For another, such as the one in a Herokuruntime.txt, changeuv venvin the hook touv venv --python 3.12. - Somewhere else. If
pyproject.tomlisn't at the top of the repository, add that directory to the hook'scd "$release"and to the service'sWorkingDirectoryandExecStart. Other release steps go in the hook, beforetrap - ERR.
Then deploy it once:
~/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#
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). 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:
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.
Back up and restore
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 up to your first deploy, then copy
the archive over from your computer:
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:
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:
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.
Update Python, FastAPI, and the rest
Updates travel through Git like any other change. On your computer:
- Packages:
uv lock --upgrade(or--upgrade-package fastapi), run your tests, commituv.lock, and push. Read FastAPI's release notes first: it's pre-1.0, so minor versions can change behavior. - Python: change
.python-version(to3.15, say) andrequires-pythoninpyproject.toml, runuv lock, commit, and push. The hook'suv syncdownloads 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 | shinstalls it in~/.local/bin, which the hook and your shell try first. - Ubuntu:
sudo apt-get update && sudo apt-get upgrade -y.
Something not working?
On the VM, the app's log, and the live release:
journalctl -u app -n 100 --no-pager
systemctl status app --no-pager
readlink ~/app
deploy: FAILEDand a rejected push: the lines above say why.The lockfile at uv.lock needs to be updated: runuv lockand commituv.lock. AModuleNotFoundErrorafter the restart: the package is missing frompyproject.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 onlysudo systemctl restart appon the VM. - The push is rejected (
non-fast-forward): someone else pushed first, maybe Shelley.git pull exe main, then push. - Redirects or
url_forlinks usehttp://: the app doesn't trust the proxy. Check--proxy-headers --forwarded-allow-ips 127.0.0.1inExecStart. - The browser can't reach the app: check that it listens on port
8000 (
ss -ltnp | grep 8000). For another port, runssh exe.dev share port my-fastapi <port>on your computer. database is locked: a write took longer than the 5-secondbusy_timeout. Keep transactions short, or move to PostgreSQL.- Scripts get
401: check theX-Exedev-Authorization: Bearerheader, 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.
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 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 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 is a Docker Compose project with PostgreSQL, a React frontend, and Traefik. Docker and Compose are preinstalled, so 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. 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 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.
Updated Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us