Back to Blog
Tutorial6 min read2026-07-13

How to Deploy Starlette with PostgreSQL on PandaStack

Ship a Starlette app properly: ASGI server setup, async SQLAlchemy, Alembic migrations, and a managed Postgres wired in via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Starlette is the ASGI toolkit that FastAPI is built on, and plenty of teams use it directly when they want async routing, WebSockets, and middleware without the extra opinionation. The trade-off: Starlette hands you almost nothing for production. No ORM, no migration tool, no settings management. You assemble those pieces yourself — which is fine, as long as you assemble them in the right order. Here's the full path from a working app to a live deployment with a managed PostgreSQL database.

A minimal production-shaped app

The pattern that matters for deployment is the lifespan handler. Anything expensive — database pools, HTTP clients — belongs there, not at module import time:

# app.py
import contextlib
import os

from sqlalchemy.ext.asyncio import create_async_engine
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route


def db_url() -> str:
    url = os.environ["DATABASE_URL"]
    # SQLAlchemy needs an explicit async driver in the scheme
    if url.startswith("postgres://"):
        url = url.replace("postgres://", "postgresql+asyncpg://", 1)
    elif url.startswith("postgresql://"):
        url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
    return url


@contextlib.asynccontextmanager
async def lifespan(app):
    engine = create_async_engine(db_url(), pool_size=5, pool_pre_ping=True)
    app.state.engine = engine
    yield
    await engine.dispose()


async def health(request):
    return JSONResponse({"status": "ok"})

app = Starlette(routes=[Route("/health", health)], lifespan=lifespan)

Two details in there are load-bearing.

First, the URL rewrite. SQLAlchemy dropped support for the postgres:// scheme years ago, and even postgresql:// gives you the synchronous psycopg2 driver. For an async Starlette app you want postgresql+asyncpg://, and the safest move is to normalize whatever connection string the platform hands you rather than assuming a format. This four-line function has saved me more deploy debugging sessions than I can count.

Second, pool_pre_ping=True. Managed databases sit behind network layers that will eventually drop an idle connection. Pre-ping costs you a trivial round-trip per checkout and eliminates the "server closed the connection unexpectedly" error that otherwise shows up at 3 a.m.

Your requirements.txt for this setup:

starlette
uvicorn[standard]
sqlalchemy[asyncio]
asyncpg
alembic
psycopg2-binary

Why both asyncpg and psycopg2-binary? The app runs async, but Alembic runs migrations synchronously. Using the sync driver for migrations is simpler than wiring Alembic's async template, and the result is identical.

The ASGI server

Uvicorn is the standard choice, and since it grew a --workers flag you no longer need Gunicorn as a process manager for typical setups:

uvicorn app:app --host 0.0.0.0 --port 8000 --workers 2

--host 0.0.0.0 is non-negotiable in a container — the default 127.0.0.1 binding means the platform's router can reach the container but not your process, which manifests as a healthy build followed by connection-refused errors. If you've never debugged that one, congratulations.

Don't reach for more workers than you have CPU. On a small container tier, one or two workers is right; async concurrency inside each worker does the heavy lifting.

Migrations with Alembic

Starlette doesn't ship a migration story, so use [Alembic](https://alembic.sqlalchemy.org/) directly:

alembic init migrations

Then point it at the environment instead of a hardcoded URL. In migrations/env.py, near the top:

import os

config.set_main_option(
    "sqlalchemy.url",
    os.environ["DATABASE_URL"].replace("postgres://", "postgresql://", 1),
)

Note this uses the plain postgresql:// scheme — sync driver, which is what Alembic wants. Generate and apply as usual:

alembic revision -m "create users table"
alembic upgrade head

Run alembic upgrade head as a separate step before the new version of the app starts serving traffic, not inside the lifespan handler. If two replicas boot at once and both try to migrate, you get lock contention at best and a half-applied migration at worst. Forward-only migrations, applied once per release, is the boring pattern that works.

The Dockerfile

Starlette has no build step, so the Dockerfile is short:

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 . .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

PYTHONUNBUFFERED=1 matters more than it looks: without it, Python buffers stdout and your logs arrive in bursts — or not at all when the process crashes, which is exactly when you needed them.

Deploying on PandaStack

The deployment itself is the least interesting part, which is how it should be:

  1. 1Provision the database first. Create a managed PostgreSQL instance (14.x or 16.x both work with everything above) from the dashboard.
  2. 2Connect the repo. Add your Starlette project as a container app. PandaStack detects the Dockerfile and builds it with rootless BuildKit in an ephemeral build pod — no Docker daemon on any host, and you watch the build logs stream live while it runs.
  3. 3Attach the database to the app. This is the step that replaces the usual credential shuffle: DATABASE_URL is injected into the container automatically. The db_url() normalizer from earlier handles the scheme, and you're connected. No copying passwords between dashboards, no secrets pasted into Slack.
  4. 4Run migrations. Execute alembic upgrade head as a one-off command against the same DATABASE_URL before traffic hits the new version.
  5. 5Push to deploy. From here on, git push triggers the build-and-deploy cycle. Set any additional environment variables (API keys, feature flags) in the dashboard rather than baking them into the image.

Cold starts and the free tier

One production behavior worth understanding: on the free tier, idle apps scale to zero and cold-start on the next request. For Starlette this is mostly painless because the lifespan handler is the only startup cost — an async engine object is created lazily, so it constructs in microseconds and the first real connection happens on the first query. Keep the lifespan handler free of blocking work (no synchronous HTTP calls, no eager cache warming) and cold starts stay short.

The free tier also caps the database at 50 connections. With pool_size=5 and two workers you're using at most 10 plus Alembic's single connection during deploys — comfortable headroom. If you later scale replicas, do that arithmetic again before the database does it for you.

Also worth knowing: free-tier apps run on preemptible nodes inside gVisor sandboxes. For a stateless ASGI app that's exactly the right trade — a restarted pod re-runs lifespan and carries on. If you're holding anything in process memory that matters, it belonged in Postgres anyway.

The whole picture

Starlette in production is four decisions: uvicorn with 0.0.0.0, async SQLAlchemy with the driver scheme normalized, Alembic run as a release step, and configuration through environment variables. None of them are hard; all of them bite when skipped. The platform's job is to make everything around those decisions — builds, TLS, routing, database provisioning — disappear.

If you want to see the git-push-to-running loop with the database wired in automatically, try it on [https://pandastack.io](https://pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also