FastAPI with PostgreSQL on a Free Tier: Async Done Properly, One Worker, and the Connection Limits That Bite
FastAPI is the framework people reach for when they want an API up this afternoon, and it deploys well on a small container because it is asynchronous: one process can hold hundreds of open requests while it waits on the database. This guide takes a FastAPI service with PostgreSQL to a free container tier, with the async database setup done properly, the worker count that actually fits a quarter of a CPU, and the connection limits you will hit if you copy a tutorial written for a bigger box.
Tested on Python 3.12 with SQLAlchemy 2 and asyncpg. FastAPI supports Python 3.10 through 3.14; nothing below depends on the FastAPI minor version.
Workers: fewer than you think
Tutorials say --workers 4. On a container with 0.25 vCPU that is four processes sharing a quarter of a core, each holding its own copy of the app and its own database pool. For an async service the concurrency comes from the event loop, not from processes: one Uvicorn worker handles many simultaneous requests as long as the handlers await rather than block. Start with one worker. Add a second only if you have CPU-bound work in request handlers (image processing, heavy serialisation), and if you do, move it off the request path instead.
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 --timeout-keep-alive 15SnapDeploy's generated Python Dockerfile detects FastAPI from requirements.txt, finds the app object and starts uvicorn <module>:app --host 0.0.0.0 --port 8000; that is a single worker, which is the right default here. Bring your own Dockerfile if you need a different Python version or a start-up step such as migrations.
The async database layer
# app/db.py
import os
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
url = os.environ["DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(url, pool_size=5, max_overflow=0, pool_pre_ping=True, pool_recycle=300)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)DATABASE_URLcomes aspostgresql://…from every provider; asyncpg needs the+asyncpgdriver marker, hence the replace.pool_size=5, max_overflow=0: a hard cap of five connections per worker. A SnapDeploy PostgreSQL Mini allows 50 connections in total; the SQLAlchemy default of 5 plus 10 overflow per process, times a few workers, is how small services exhaust a database.pool_pre_pingandpool_recycle=300: after a free container wakes from sleep, or after a provider paused a free database, stale connections are detected and replaced instead of raising on the first request.
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from sqlalchemy import text
from .db import engine, SessionLocal
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
async def get_session():
async with SessionLocal() as session:
yield session
@app.get("/health")
async def health(session=Depends(get_session)):
await session.execute(text("SELECT 1"))
return {"status": "ok"}The lifespan handler disposes the pool on shutdown, which matters on a container that is stopped when it sleeps: connections are closed cleanly instead of timing out on the database side. The health route does one round trip and is what you will curl after every deploy.
Migrations
Run Alembic at container start, before Uvicorn. With one container this is safe and it keeps the schema and the code in the same deploy:
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 ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"]Alembic's env.py needs the sync driver URL for the migration run (postgresql:// with psycopg, or use the async template that Alembic ships). Keep requirements.txt pinned: fastapi, uvicorn[standard], sqlalchemy[asyncio], asyncpg, alembic, psycopg[binary].
Where the PostgreSQL comes from
| Option | Cost | Fits when |
|---|---|---|
| SnapDeploy PostgreSQL Mini | $29/month (1 GB RAM, 5 GB, 50 connections) | The API is a real product; connection string injected as DATABASE_URL, daily backups, pgweb in the browser. |
| DB Sprint Pack | $1 for 12 hours of the same | Testing the whole stack, a demo, a hackathon. |
| Neon free plan | Free (0.5 GB, 100 compute-hours a month) | A hobby API; the database suspends when idle and the first query after that is slow, which pool_pre_ping handles. |
| Supabase free plan | Free (500 MB, two projects) | Same, with a seven-day inactivity pause to plan around. |
Deploying
- Push to GitHub. Containers → Deploy from GitHub → pick the repository; port 8000.
- Attach a PostgreSQL add-on or paste
DATABASE_URL; add any API keys the service needs. - Deploy, then open
/healthand/docs(the interactive documentation FastAPI generates) on the container URL. - Watch the runtime log for
Application startup complete. If Alembic fails, the container exits before Uvicorn starts and the log says why.
Test the image locally
docker build -t api .
docker run --rm -p 8000:8000 -e DATABASE_URL=postgresql://user:pass@host.docker.internal:5432/app api
curl -s http://localhost:8000/healthhost.docker.internal reaches a Postgres on your machine from inside the container. If the health check fails here, it fails on the platform too, and this is the cheaper place to read the traceback.
Where free stops
- The container sleeps after 15 minutes without requests and wakes in about a minute. An API that a mobile app calls every few minutes stays awake and spends its hours; one called a few times a day sleeps between calls and costs nothing in between.
- 100 container-hours a month across four containers. Background tasks scheduled inside the process only run while the container is awake.
- WebSocket endpoints work, but socket traffic does not count as activity, so a client that only holds a socket open will find the container asleep after 15 minutes.
- Custom domains and no sleeping: Always-On, $12/month per container.
CORS and the container hostname
If a browser front end on another host calls this API, add CORSMiddleware with the front end's origin, and read that origin from an environment variable rather than hard-coding it: the container URL on containers.snapdeploy.app and a later custom domain are different origins, and a wildcard with credentials is refused by browsers. Keep allow_credentials=True only if you actually use cookies.
Numbers used: free tier 4 containers, 512 MB, 0.25 vCPU, 100 container-hours a month, no card (free tier docs); PostgreSQL Mini $29/month with 50 connections and the $1 DB Sprint Pack (add-on pricing); Neon and Supabase free plans as published in September 2026.