Deploy FastAPI with systemd and Caddy
Run a Python FastAPI service in a locked-down virtual environment, supervise it with systemd, and add automatic HTTPS through Caddy.

A small FastAPI service needs three layers: an application server, a supervisor, and an HTTPS reverse proxy. Uvicorn serves the ASGI app, systemd keeps it alive, and Caddy handles certificates.
Create an isolated application user
sudo useradd --system --create-home --home-dir /opt/inventory-api \
--shell /usr/sbin/nologin inventory
sudo apt update
sudo apt install -y python3-venv python3-pip curl
Releases live under /opt/inventory-api/releases, and a current symlink selects the one that is live. Create the first release directory and copy your application code into its app folder, owned by inventory:
sudo -u inventory mkdir -p /opt/inventory-api/releases/v1/app
With the code in place, create a virtual environment inside the release, install locked dependencies, and point the symlink at the release:
sudo -u inventory python3 -m venv /opt/inventory-api/releases/v1/venv
sudo -u inventory /opt/inventory-api/releases/v1/venv/bin/pip install --upgrade pip
sudo -u inventory /opt/inventory-api/releases/v1/venv/bin/pip install -r /opt/inventory-api/releases/v1/app/requirements.txt
sudo -u inventory ln -sfn /opt/inventory-api/releases/v1 /opt/inventory-api/current
Pin exact versions in requirements.txt or generate a lock file in CI.
Add a health endpoint
Save this as main.py in the release's app directory, /opt/inventory-api/releases/v1/app/main.py. The systemd unit you create next imports it as main:app:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health", include_in_schema=False)
async def health():
return {"status": "ok"}
Keep this endpoint cheap. If database availability is essential, expose a separate readiness endpoint that performs a short, bounded query.
Supervise Uvicorn
Create /etc/systemd/system/inventory-api.service. One flag in it deserves a caution before you paste: --proxy-headers tells Uvicorn to trust the X-Forwarded-For and X-Forwarded-Proto headers it receives, which is safe here only because the server binds to 127.0.0.1 and nothing but Caddy can reach it. Never combine --proxy-headers with a public bind address, or any client can spoof its IP address and scheme:
[Unit]
Description=Inventory FastAPI service
After=network.target
[Service]
User=inventory
Group=inventory
WorkingDirectory=/opt/inventory-api/current/app
EnvironmentFile=/opt/inventory-api/app.env
ExecStart=/opt/inventory-api/current/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 2 --proxy-headers
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/inventory-api/data
[Install]
WantedBy=multi-user.target
ProtectSystem=strict mounts the filesystem read-only for this service, and ReadWritePaths carves out the one directory it may write to. systemd refuses to start the unit if that path does not exist, so create it now even if your app writes nothing yet:
sudo install -d -o inventory -g inventory /opt/inventory-api/data
The unit also loads secrets from /opt/inventory-api/app.env and will not start without the file. Create it owned by root with mode 600; systemd reads it as root before dropping to the inventory user, so nothing else needs access:
sudo touch /opt/inventory-api/app.env
sudo chown root:root /opt/inventory-api/app.env
sudo chmod 600 /opt/inventory-api/app.env
Open it with sudoedit /opt/inventory-api/app.env and add your settings, one KEY=value per line:
DATABASE_URL=postgresql://inventory:change-me@127.0.0.1:5432/inventory
Now enable the service and check it:
sudo systemctl daemon-reload
sudo systemctl enable --now inventory-api
curl --fail http://127.0.0.1:8000/health
If the service fails, journalctl -u inventory-api -n 100 usually tells you whether the cause is an import, permission, or environment error.
Put Caddy in front
Caddy is not installed yet on a fresh VPS. Install it from the project's official package repository, which stays more current than the Ubuntu archive:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
Point your domain's DNS A record (and AAAA record if the VPS has IPv6) at the server before going further: Caddy can only obtain a certificate for a name that already resolves here. Then replace the contents of /etc/caddy/Caddyfile with:
api.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8000 {
health_uri /health
health_interval 30s
}
}
Reload Caddy, then verify HTTPS from another network:
sudo systemctl reload caddy
curl --fail https://api.example.com/health
Uvicorn stays bound to loopback, so the only ports that should be public are 22 for SSH and 80 and 443 for Caddy. Enforce that with ufw, which ships with Ubuntu. Enabling the firewall drops every connection that is not explicitly allowed, including SSH, so add the OpenSSH rule before running ufw enable, and keep your current session open while you confirm from a second terminal that you can still log in. Locking yourself out is a real risk, and the recovery console is an awkward back door.
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ss -tlnp
The ss output should show Uvicorn on 127.0.0.1:8000 and Caddy on 80 and 443; nothing else should be listening on a public address.
Choose worker count with evidence
Two workers is a reasonable starting point on a two-vCPU VPS; four workers plus a database and a build process on the same host may cause memory pressure. Run a small load test against staging before adding more. Async endpoints help when requests wait on network I/O, but CPU-heavy image processing still consumes a core and belongs in a task queue.
Deploy a new release
Deploy by creating a new release directory under /opt/inventory-api/releases, installing its dependencies into a fresh virtual environment, switching the current symlink, and restarting the service. The unit resolves WorkingDirectory and ExecStart through current, so flipping the symlink is what selects the running code. Keep the previous release directory: pointing current back at it and restarting is an immediate rollback.
Run migrations as a separate step
Do not run a schema migration from every Uvicorn worker on startup. Run it once during deploy, before switching traffic.
Migrations can destroy data. An upgrade that drops or rewrites a column is irreversible, and a downgrade script will not bring lost rows back. Take a full database backup now, before running the migration, especially for any migration that cannot be rolled back. For Postgres that is one command, and restoring from it is the rollback plan:
sudo -u postgres pg_dump --format=custom inventory \
-f /var/backups/inventory-pre-migrate.dump
Adapt the database name, and use your engine's equivalent (mysqldump, a snapshot) if you are not on Postgres. With the backup done and the new release staged at /opt/inventory-api/releases/v2, run the migration from that release, confirm the still-running old version is still serving traffic without errors (watch its logs, not just the cheap /health probe), then switch:
sudo -u inventory /opt/inventory-api/releases/v2/venv/bin/alembic \
-c /opt/inventory-api/releases/v2/app/alembic.ini upgrade head
curl --fail http://127.0.0.1:8000/health
sudo -u inventory ln -sfn /opt/inventory-api/releases/v2 /opt/inventory-api/current
sudo systemctl restart inventory-api
Use backward-compatible migrations when old and new application versions overlap.
Handle proxy and worker details
Set --proxy-headers only when Uvicorn is reachable exclusively through your trusted proxy, as it is here with the loopback bind; if the port is ever exposed directly, clients can forge X-Forwarded-* headers and spoof their IP and scheme. For long-running requests, configure Caddy timeouts intentionally and move work beyond a request lifetime into a queue.
Keep application logs on stdout/stderr so journald captures them, and never log authorization headers, cookies, or sensitive payloads.
Continue reading