Litestar is what you reach for when you want FastAPI-style ergonomics — typed handlers, dependency injection, OpenAPI for free — with a stricter, faster core (msgspec serialization instead of Pydantic-everywhere, though Pydantic models work fine too). It's an ASGI framework, which means production deployment follows the ASGI playbook: a real server process, an explicit port binding, and async database drivers with one URL-scheme gotcha that catches almost everyone. Here's the full path to production.
Dev server vs production server
Litestar ships a CLI, and litestar run is great for development — auto-reload, nice error output. It is not your production entrypoint. In production you run the ASGI app under Uvicorn directly:
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 2Here app:app means "the app object in app.py":
# app.py
from litestar import Litestar, get
@get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
app = Litestar(route_handlers=[health])Two notes on that command:
--host 0.0.0.0is mandatory in a container. Uvicorn's default of127.0.0.1binds to the loopback interface, and the platform's router will never reach your app — the most common "it works locally, 502s in prod" cause for any ASGI framework.--workers 2forks worker processes for you. On a small container (say 0.5–1 CPU), 1–2 workers is right; async handlers already give you concurrency within a worker, so don't stack dozens of processes into a tiny CPU allocation.
Install everything you need with the standard extra, which pulls in Uvicorn:
litestar[standard]
sqlalchemy[asyncio]
asyncpg
alembicThe DATABASE_URL scheme gotcha
This is the section that saves you an hour. Managed platforms hand your app a DATABASE_URL that looks like:
postgresql://user:password@host:5432/dbnameBut async SQLAlchemy needs to know which driver to use, and a bare postgresql:// scheme selects the synchronous psycopg2 driver — which then fails inside an async app with errors about greenlets or a missing module. For asyncpg, the URL must be:
postgresql+asyncpg://user:password@host:5432/dbnameNobody's platform injects the +asyncpg variant, because the URL has to work for every consumer. So normalize it in your config, once:
# config.py
import os
def get_database_url() -> str:
url = os.environ["DATABASE_URL"]
if url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return urlThis also handles the legacy postgres:// scheme some platforms still emit. Do the rewrite here and nowhere else — your app code and Alembic both call this one function.
Wiring SQLAlchemy into Litestar
Litestar's first-party SQLAlchemy integration comes via the Advanced Alchemy plugin, which manages the engine and hands each request an async session through dependency injection:
# app.py
from litestar import Litestar, get
from litestar.plugins.sqlalchemy import SQLAlchemyAsyncConfig, SQLAlchemyPlugin
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from config import get_database_url
db_config = SQLAlchemyAsyncConfig(connection_string=get_database_url())
@get("/health")
async def health(db_session: AsyncSession) -> dict[str, str]:
await db_session.execute(text("SELECT 1"))
return {"status": "ok", "database": "up"}
app = Litestar(
route_handlers=[health],
plugins=[SQLAlchemyPlugin(config=db_config)],
)The plugin injects db_session into any handler that declares it — no session factories or context managers scattered through your handlers. A health endpoint that actually touches the database, like the one above, is worth having: it catches bad credentials and exhausted connection pools at deploy time instead of on the first user request.
Migrations with Alembic
For an async engine, generate the Alembic scaffolding with the async template:
alembic init -t async migrationsPoint migrations/env.py at the same normalized URL your app uses:
# in migrations/env.py
from config import get_database_url
config.set_main_option("sqlalchemy.url", get_database_url())Then the workflow is the standard one:
alembic revision --autogenerate -m "create users table"
alembic upgrade headCommit the migrations/ directory. In production, run only alembic upgrade head, and run it as a step before the new version takes traffic — not inside the app's startup hook. Two replicas booting simultaneously and racing on the Alembic version table is a genuinely annoying failure mode, and it's entirely avoidable with a start script:
#!/bin/sh
set -e
alembic upgrade head
exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 2(Advanced Alchemy also ships its own migration commands under the Litestar CLI; they wrap Alembic, so everything above still applies — pick one interface and stay consistent.)
A Dockerfile for Litestar
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 && chmod +x start.sh
USER appuser
EXPOSE 8000
CMD ["./start.sh"]PYTHONUNBUFFERED=1 keeps your logs streaming in real time instead of arriving in buffered chunks. asyncpg ships binary wheels, so the slim image needs no compiler toolchain — the image stays small and the build stays fast.
Deploying on PandaStack
The deploy itself:
- 1Create a managed PostgreSQL instance (14.x or 16.x run in production) from the PandaStack dashboard.
- 2Connect your Litestar repo as a container app. With the Dockerfile committed, it's used as-is; without one, the Python buildpack is auto-detected and you can override the start command to the Uvicorn invocation above.
- 3Attach the database to the app —
DATABASE_URLis injected into the environment automatically, and theget_database_url()function handles the asyncpg scheme rewrite. No credentials to copy, nothing to paste into a secrets page by hand. - 4Add any other environment variables your app reads (API keys,
LITESTAR_APP=app:appif you use the Litestar CLI anywhere in production tooling). - 5Push. The image builds in a rootless BuildKit job with logs streaming live, then goes out via Helm. From then on, every push to the connected branch is a deploy, and deployment history gives you rollbacks when a migration or release goes sideways.
On the free tier, idle apps scale to zero and cold-start on the next request — fine for a side project or an internal tool, worth upgrading past for latency-sensitive traffic, since paid tiers run warm on stable nodes.
Pre-flight checklist
- Uvicorn with
--host 0.0.0.0, notlitestar run. DATABASE_URLrewritten topostgresql+asyncpg://in exactly one place, shared by the app and Alembic.alembic upgrade headas a release step, never in the app's startup path with multiple replicas.- A health endpoint that executes
SELECT 1, so a broken database connection fails the deploy loudly. PYTHONUNBUFFERED=1in the image, so the logs you're tailing are actually current.
Litestar's typed, plugin-based design means most of this is configuration you write once and stop thinking about — which is the point. If you want to see the whole loop with the database wired in for you, try it on https://pandastack.io.