FastAPI is easy to run locally — uvicorn main:app --reload and you're done. Production is where the questions start: how many workers, which server process, how do migrations run, and where does the database URL come from. None of it is hard, but the defaults that work on your laptop are wrong for a server. Here's the full path from a working local app to a deployed one with a managed PostgreSQL database.
The production server command
The --reload flag you use in development watches the filesystem and restarts on changes. Drop it in production — it costs CPU and adds a subprocess you don't want. The minimal production command is:
uvicorn app.main:app --host 0.0.0.0 --port 8000Two things matter here. --host 0.0.0.0 binds to all interfaces so the platform's router can actually reach the process — the default 127.0.0.1 only accepts traffic from inside the container, which shows up as a healthy-looking app that never receives a request. And the port needs to match whatever your platform expects; 8000 is the uvicorn convention.
For multiple worker processes, the long-standing pattern is gunicorn managing uvicorn workers:
gunicorn app.main:app \
--workers 2 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000On a small container (say 0.5–1 CPU), 1–2 workers is right. More workers than cores just adds memory pressure and context switching. If you're scaling horizontally with multiple container replicas anyway, a single worker per container is a perfectly reasonable design.
Settings from environment variables
Hardcoded config is the thing that breaks first when you deploy. Use pydantic-settings so the app reads everything from the environment and fails loudly if something required is missing:
# app/settings.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
debug: bool = False
settings = Settings()Settings() raises at import time if DATABASE_URL isn't set. That's the behavior you want — a crash at boot with a clear error, not a 500 on the first query.
Connecting to PostgreSQL
Managed platforms hand you one connection string, DATABASE_URL, in the form postgresql://user:pass@host:5432/dbname. If you're using SQLAlchemy with the async engine, you need the asyncpg driver in the scheme, and some providers still emit the legacy postgres:// prefix that SQLAlchemy rejects. Normalize both in one place:
# app/db.py
from sqlalchemy.ext.asyncio import create_async_engine
from app.settings import settings
url = settings.database_url
if url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
async_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(async_url, pool_size=5, max_overflow=5)Keep the pool small. Managed databases have connection limits (on PandaStack the free tier allows 50 connections, Pro 300), and every worker in every replica opens its own pool. Two replicas × two workers × pool_size 5 is already 20 connections before overflow.
Alembic migrations
FastAPI has no built-in migration story — Alembic is the standard. The gotcha is that alembic.ini wants a static sqlalchemy.url, which you should never commit with real credentials. Override it from the environment in migrations/env.py:
# migrations/env.py — near the top, after config is defined
import os
config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])Note that Alembic runs synchronously, so use the plain postgresql:// URL here (with psycopg or psycopg2 installed), not the +asyncpg one. Then the deploy-time command is:
alembic upgrade headRun this as a release step before new code takes traffic, not inside the FastAPI startup event. If migrations run at app boot, two replicas starting at once will race each other on the same DDL, and a slow migration makes your health checks fail while it grinds.
A Dockerfile that behaves
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]PYTHONUNBUFFERED=1 matters more than people think — without it, Python buffers stdout and your logs arrive in bursts (or not at all when the process dies), which makes live log streaming useless exactly when you need it. Copying requirements.txt before the source keeps the pip layer cached across builds, so pushing a code change doesn't reinstall every dependency.
Add a health endpoint while you're at it:
@app.get("/healthz")
async def healthz():
return {"status": "ok"}Keep it dependency-free — don't touch the database in a health check, or a brief DB hiccup takes your whole app out of rotation.
Deploying on PandaStack
With the app containerized (or not — a plain requirements.txt project works too), the deploy itself is short:
- 1Provision a managed PostgreSQL instance (14.x or 16.x) from the [dashboard](https://dashboard.pandastack.io).
- 2Connect your Git repository as a container app. If there's a Dockerfile, it's used as-is; otherwise the Python buildpack auto-detects the app and figures out the build. You can override the install and start commands if the detection guesses wrong.
- 3Attach the database to the app.
DATABASE_URLis injected into the container's environment automatically — the settings class above picks it up with zero extra config, and there's no copying credentials between tabs. - 4Set any remaining environment variables (API keys,
DEBUG=false) in the app's settings. - 5Push to your branch. The build runs in a rootless BuildKit job on Kubernetes, and you can watch the build logs stream live while it works.
Every subsequent git push rebuilds and redeploys. If a deploy goes sideways, you can roll back to a previous deployment from the dashboard.
One honest note about the free tier: apps scale to zero when idle, so the first request after a quiet period pays a cold start while the container spins back up. For a side project or an internal tool that's a fine trade for a $0 bill; for a latency-sensitive production API, use a paid tier where the app stays warm.
Checklist before you call it done
--host 0.0.0.0, no--reload, worker count matched to CPUDATABASE_URLread from the environment, scheme normalized for asyncpg- Connection pool sized against the database's connection limit
alembic upgrade headas a release step, not at app startupPYTHONUNBUFFERED=1so logs stream in real time- A
/healthzroute that doesn't hit the database
That's the whole distance from uvicorn --reload to a real deployment. If you want to see it end to end with the database wired in for you, try it on https://pandastack.io.