Deploy FastAPI to Hetzner
Follow this on your own server. Start free, no card needed
uvicorn main:app --reload is how a FastAPI app runs on a laptop; what it needs for production is a real process manager, a database that survives a restart, migrations that run before new code goes live, and HTTPS in front of it. None of that is specific to FastAPI exactly, but the details differ enough from a Flask or Django deploy, an ASGI server instead of WSGI, Alembic instead of Django’s built-in migrations, that copying a generic Python Dockerfile usually leaves something half-configured. This guide builds the real thing in Docker on a Hetzner Cloud server, with Caddy handling certificates, and ends with a FastAPI app running at your own domain that you know how to update and roll forward.
What you’ll need
- A Hetzner Cloud server. A CX23 (2 vCPU, 4 GB memory) at €5.99/month excluding VAT runs a single FastAPI app with its Postgres database alongside it comfortably; move up to a CX33 (4 vCPU, 8 GB, €8.99/month) if the app also does real background work.
- A domain, with DNS already pointed at the server before requesting HTTPS.
- A Hetzner Cloud account and an API token (Security → API Tokens, Read & Write) if you want to provision the server through the API rather than the console.
- A FastAPI app in a Git repository. This guide uses FastAPI 0.161.1, which requires Python 3.10 or newer; the Dockerfile below pins Python 3.13.
- Docker and the Compose plugin, either locally to test the build first or directly on the server.
Step 1: Create the server
In the Hetzner Cloud Console, create a project if needed, then Add Server: pick Ubuntu 24.04, the CX23 shared vCPU type, a location (Nuremberg, Falkenstein and Helsinki all stay in the EU), add your SSH key, and create it.
ssh root@your-server-ip
You should land at a root shell with no password prompt.
Step 2: Install Docker
curl -fsSL https://get.docker.com | sh
docker --version
docker compose version
Step 3: Write the Dockerfile
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
fastapi run is the production command FastAPI’s own docs point at; it runs Uvicorn underneath without a separate command to remember. libpq-dev and build-essential are there for psycopg, which needs to compile against Postgres’s client library; drop them if your requirements.txt uses psycopg[binary] or asyncpg instead, since both ship prebuilt wheels.
A minimal requirements.txt for this guide:
fastapi[standard]>=0.161
psycopg[binary]>=3.2
sqlalchemy>=2.0
alembic>=1.13
Build it once to confirm it compiles cleanly:
docker build -t my-fastapi-app .
Step 4: Write the Compose file
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
expose:
- "8000"
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
db-data:
caddy-data:
caddy-config:
Your .env file on the server:
DB_NAME=app
DB_USER=app
DB_PASSWORD=change-this
DATABASE_URL=postgresql+psycopg://app:change-this@db:5432/app
Validate before starting:
docker compose config
Step 5: Write the Caddyfile
api.example.com {
reverse_proxy app:8000
}
Step 6: First boot and migrations
docker compose up -d
docker compose exec app alembic upgrade head
docker compose ps
You should see app, db and caddy all running, and https://api.example.com should respond with a valid certificate. FastAPI’s interactive docs live at /docs by default; visiting it should show your API’s actual endpoints.
Step 7: Deploy new versions
git pull
docker compose build app
docker compose up -d --no-deps app
docker compose exec app alembic upgrade head
Rebuilding only app leaves db and caddy untouched, so the certificate and the database both stay up through the deploy. Running the migration after the new container is up, rather than as part of the build, means a migration failure shows up as a clear command error instead of a half-deployed image.
Backups
Postgres in the db container is the one piece of state in this stack that isn’t reproducible from Git. Schedule a daily dump off the server:
docker compose exec -T db pg_dump -U app app | gzip > backup-$(date +%F).sql.gz
Copy that file to object storage or another machine on a cron job, and periodically restore it into a scratch database to confirm it’s actually usable before you need it for real.
The shortcut: do it with Railyard
Deploy FastAPI to Hetzner runs the same recipe from a repository URL, without writing the Dockerfile, Compose file or Caddyfile yourself. Connect a server with a token from the Hetzner Cloud Console, or bring one you’ve already provisioned. Railyard detects FastAPI from requirements.txt or pyproject.toml, serves main:app on $PORT with uvicorn (pointed at your own module automatically if the app lives somewhere else), and recognizes psycopg, asyncpg or SQLAlchemy as a dependency to provision Postgres with DATABASE_URL injected. Set alembic upgrade head as the release command under Settings → Deploy to migrate on every release, the same as Step 6 above but run automatically before each new version takes traffic. Any process in a Procfile becomes a worker you can scale independently of the web process. See the deploy from a repository docs for the exact detection rules.
Troubleshooting
connection refusedon first boot:appstarted before Postgres finished initializing.depends_onwith a healthcheck condition (as in Step 4) should prevent this, but if you remove the healthcheck, add a short retry loop around the first migration command instead of assuming the database is ready the instant its container starts.- 500 errors with no detail in the response: FastAPI doesn’t expose tracebacks in responses by default outside of debug tooling; check
docker compose logs appfor the real exception. ModuleNotFoundErrorforpsycopgat runtime despite a clean build: usually a mismatch betweenpsycopg(thepure Pythonvariant) andpsycopg[binary]orpsycopg2-binaryacrossrequirements.txtedits; pin one explicitly rather than letting a stale lockfile resolve a different one than intended.- HTTPS never issues, Caddy keeps retrying: DNS isn’t pointing at the server yet.
dig +short api.example.comshould match the server’s IP. - Alembic says “Target database is not up to date” on deploy: a previous deploy’s migration didn’t run, often because Step 7’s migration command was skipped manually once. Run
docker compose exec app alembic currentto see where the database actually is versusalembic headsfor where the code expects it to be.
FAQ
Why fastapi run instead of calling uvicorn directly? They do the same thing under the hood; fastapi run is the command FastAPI’s own docs recommend for production as of the versions this guide covers, since it picks sensible defaults without extra flags.
Should I use multiple Uvicorn workers in one container? FastAPI’s own deployment docs recommend against it for anything running under Docker Compose, Kubernetes, or similar: let the orchestrator run multiple container replicas instead of one container juggling multiple worker processes, since replicas can be restarted, scaled and health-checked independently.
Postgres or something else? SQLAlchemy and most of the FastAPI ecosystem are database-agnostic; this guide uses Postgres since it’s the most common pairing, but swapping the db image and DATABASE_URL scheme works the same way for MySQL.
Is a CX23 really enough? For a single API serving typical request volumes, yes; 4 GB splits comfortably between the app container and Postgres. CPU-heavy background work (large data processing, ML inference) is a reason to size up.
What if my app uses an app factory instead of a module-level app variable? Point the Dockerfile’s CMD or Railyard’s detected command at it explicitly, for example fastapi run app/main.py --port 8000 becomes the equivalent call against a factory function name if that’s how your app is structured; this guide’s example assumes a plain app instance.
Does this work with async-only endpoints and sync endpoints mixed in one app? Yes; FastAPI runs synchronous path operations in a thread pool automatically so they don’t block the event loop, which is part of why a single Uvicorn process handles a mix of both without extra configuration on your side.
A FastAPI app on a Hetzner server you provisioned yourself costs €5.99 a month, with every dependency, Python version and migration step explicit in three files you control. Shipping a new release after they exist is a rebuild of one container and a migration command, nothing hidden in between.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.