Back to Blog
Tutorial6 min read2026-07-11

How to Deploy FastAPI with PostgreSQL on PandaStack

Deploy a FastAPI app with a managed Postgres database: production server setup, Alembic migrations, DATABASE_URL wiring, and going live via Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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 8000

Two 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:8000

On 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 head

Run 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:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x) from the [dashboard](https://dashboard.pandastack.io).
  2. 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.
  3. 3Attach the database to the app. DATABASE_URL is 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.
  4. 4Set any remaining environment variables (API keys, DEBUG=false) in the app's settings.
  5. 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 CPU
  • DATABASE_URL read from the environment, scheme normalized for asyncpg
  • Connection pool sized against the database's connection limit
  • alembic upgrade head as a release step, not at app startup
  • PYTHONUNBUFFERED=1 so logs stream in real time
  • A /healthz route 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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also