This guide shows you how to deploy FastAPI to production with Docker: a pinned Dockerfile, Postgres with Alembic migrations, HTTPS behind a proxy, health checks and a clean shutdown. It matters because fastapi dev on your laptop hides the problems that break real deploys: redirects that switch to http://, a container that takes 10 seconds to stop, migrations that run at the wrong moment, and API docs left open to the world. Every file below is complete. Copy them, change the names, and ship.

Try it on Hubfly space: run the finished app with HTTPS for about $4.64 a month.

How to deploy FastAPI to production: what changes from dev

Development Production
Server fastapi dev (auto-reload) fastapi run (no reload, binds 0.0.0.0)
Dependencies whatever pip install gave you pinned versions in requirements.txt
User you a non-root user inside the container
Database schema create_all() on startup Alembic migrations, run once per deploy
HTTPS none a proxy terminates TLS; the app trusts its headers
API docs open off, or behind a login
Health "it loads" /healthz that checks the database

Step 1: Write the app

The example is a small orders API called shop-api. Layout:

shop-api/ ├── app/ │ ├── __init__.py (empty) │ ├── db.py │ ├── models.py │ └── main.py ├── migrations/ (created by Alembic in Step 3) ├── alembic.ini ├── requirements.txt ├── Dockerfile ├── .dockerignore └── compose.yaml 

app/db.py reads the database URL from the environment, so the same image runs everywhere:

import os from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker engine = create_engine( os.environ["DATABASE_URL"], pool_size=5, # per worker process  max_overflow=5, pool_pre_ping=True, # drop dead connections after a DB restart ) SessionLocal = sessionmaker(engine) class Base(DeclarativeBase): pass 

app/models.py:

from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column from app.db import Base class Order(Base): __tablename__ = "orders" id: Mapped[int] = mapped_column(primary_key=True) item: Mapped[str] = mapped_column(String(200)) quantity: Mapped[int] 

app/main.py adds three production details: docs that are off unless you ask for them, a health check that really touches the database, and a clean shutdown that closes the connection pool.

import os from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, HTTPException, Request from pydantic import BaseModel from sqlalchemy import select, text from sqlalchemy.orm import Session from app.db import SessionLocal, engine from app.models import Order SHOW_DOCS = os.getenv("SHOW_DOCS") == "1" @asynccontextmanager async def lifespan(app: FastAPI): yield engine.dispose() # close DB connections cleanly on shutdown  app = FastAPI( title="shop-api", lifespan=lifespan, # openapi_url=None turns off /docs, /redoc AND /openapi.json  openapi_url="/openapi.json" if SHOW_DOCS else None, ) def get_db(): with SessionLocal() as session: yield session class OrderIn(BaseModel): item: str quantity: int @app.get("/healthz") def healthz(db: Session = Depends(get_db)): try: db.execute(text("SELECT 1")) except Exception: raise HTTPException(status_code=503, detail="database unreachable") return {"status": "ok"} @app.post("/orders", status_code=201) def create_order(order: OrderIn, db: Session = Depends(get_db)): row = Order(item=order.item, quantity=order.quantity) db.add(row) db.commit() return {"id": row.id, "item": row.item, "quantity": row.quantity} @app.get("/orders") def list_orders(db: Session = Depends(get_db)): rows = db.scalars(select(Order).order_by(Order.id)).all() return [{"id": r.id, "item": r.item, "quantity": r.quantity} for r in rows] @app.get("/whoami") def whoami(request: Request): # shows what the app believes about the caller (proxy headers check)  return {"client": request.client.host, "scheme": request.url.scheme} 

Why openapi_url=None and not docs_url=None: setting only docs_url=None, redoc_url=None hides the pages but still serves your full schema at /openapi.json (status 200). Turning off openapi_url returns 404 on all three. Note that fastapi run still prints "Documentation at http://0.0.0.0:8000/docs" at startup; ignore that line. Set SHOW_DOCS=1 on staging when you want them.

The endpoints use plain def, not async def, because SQLAlchemy's session here is synchronous. FastAPI runs def endpoints in a thread pool, so they don't block the event loop.

Step 2: Pin your dependencies

requirements.txt:

fastapi[standard]==0.162.2 sqlalchemy==2.1.8 psycopg[binary]==3.3.8 alembic==1.20.1 

Why fastapi[standard]: plain fastapi doesn't include the fastapi command or Uvicorn. The standard extra brings both, plus uvloop and httptools for speed. Pin exact versions so a rebuild next month gives you the same app.

Step 3: Add Alembic migrations

Install the packages locally, then create the migrations folder:

pip install -r requirements.txt alembic init migrations 

Open migrations/env.py and make three changes. Add import os at the top. Under config = context.config, add:

# "%" must be doubled for configparser (URL-encoded passwords contain %) config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"].replace("%", "%%")) 

And replace target_metadata = None with:

from app.db import Base # noqa: E402 from app import models # noqa: E402,F401  target_metadata = Base.metadata 

The % gotcha: if your database password has special characters, you URL-encode them (p@ss#word becomes p%40ss%23word). Pass that URL straight to set_main_option and Alembic crashes with ValueError: invalid interpolation syntax ... at position 28, because alembic.ini values go through Python's configparser. Doubling % fixes it, and the app itself reads the URL unchanged.

Also delete the example URL in alembic.ini so nobody thinks it's used:

sqlalchemy.url = 

Generate the first migration against a local database, then commit the file in migrations/versions/:

export DATABASE_URL="postgresql+psycopg://shop:your-password@localhost:5432/shop" alembic revision --autogenerate -m "create orders" alembic upgrade head 

Why migrations run as their own step: if you put alembic upgrade head inside the app's startup, it runs once per worker and once per replica, and a failed migration still lets the app start against the old schema. A one-off job that must exit with code 0 before the API starts is easier to reason about. Step 5 wires that up.

Step 4: Write the Dockerfile

FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /code # Dependencies first, so code changes don't reinstall them COPY requirements.txt . RUN pip install -r requirements.txt COPY alembic.ini . COPY migrations ./migrations COPY app ./app # Never run the app as root RUN useradd --create-home --uid 10001 appuser USER appuser EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"] # Exec form: the app gets SIGTERM directly and shuts down cleanly CMD ["fastapi", "run", "app/main.py", "--port", "8000"] 

And .dockerignore, so secrets and junk never reach the image:

.git .env .venv venv __pycache__ *.pyc 

Three lines in that Dockerfile do most of the work:

  • USER appuser: if someone finds a hole in your app, they land as an unprivileged user, not root.
  • HEALTHCHECK uses Python's own urllib because the slim image has no curl. urlopen raises on a 503, so the check fails when the database is down.
  • Exec-form CMD (the JSON array). This one is easy to get wrong. A common shortcut is CMD alembic upgrade head && fastapi run app/main.py. That shell form makes sh process 1, and sh doesn't pass SIGTERM on to your app. docker stop then waits its full 10 seconds and kills the container (exit code 137). With the exec form, this app stops in under half a second with exit code 0, after logging "Application shutdown complete". If you must chain commands in a shell, end with exec fastapi run ....

Don't use the old tiangolo/uvicorn-gunicorn-fastapi image. It's deprecated, and you don't need Gunicorn any more (see the FAQ).

Step 5: Run the whole stack with Compose

compose.yaml:

services: db: image: postgres:18 environment: POSTGRES_USER: shop POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: shop volumes: - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U shop -d shop"] interval: 5s timeout: 3s retries: 10 restart: unless-stopped migrate: build: . command: ["alembic", "upgrade", "head"] environment: DATABASE_URL: postgresql+psycopg://shop:${POSTGRES_PASSWORD}@db:5432/shop depends_on: db: condition: service_healthy restart: "no" api: build: . environment: DATABASE_URL: postgresql+psycopg://shop:${POSTGRES_PASSWORD}@db:5432/shop FORWARDED_ALLOW_IPS: "*" ports: - "127.0.0.1:8000:8000" depends_on: migrate: condition: service_completed_successfully restart: unless-stopped stop_grace_period: 30s volumes: pgdata: 

Create a .env file next to it (and keep it out of Git):

POSTGRES_PASSWORD=change-me-long-random 

Start it and check it:

docker compose up -d --build curl http://127.0.0.1:8000/healthz curl -X POST http://127.0.0.1:8000/orders \ -H "content-type: application/json" \ -d '{"item":"coffee beans","quantity":2}' 

Once the database is up, the first call returns {"status":"ok"} and the second creates order 1: {"id":1,"item":"coffee beans","quantity":2}.

What each part buys you:

  • depends_on chain: the database must be healthy, then migrate must exit 0, then the API starts. A broken migration stops the deploy instead of serving errors.
  • /var/lib/postgresql, not /var/lib/postgresql/data: Postgres 18 images moved the data directory. The old path makes the container refuse to start. Use a password without $ in .env: Compose treats $word as a variable.
  • 127.0.0.1:8000:8000: the API port is open only to the machine itself. Only your proxy reaches it, which is what makes FORWARDED_ALLOW_IPS: "*" safe (next step).

Step 6: Put HTTPS in front and trust the proxy

FastAPI doesn't do TLS. A proxy in front does, then forwards the request with X-Forwarded-Proto: https and X-Forwarded-For: <client IP>. fastapi run reads those headers, but by default it only trusts them from 127.0.0.1.

Here's what that means in practice. With the default setting and the proxy on another IP, a request for /orders/ with X-Forwarded-Proto: https returns:

HTTP/1.1 307 Temporary Redirect location: http://api.example.com/orders 

Your HTTPS site just sent the browser to http://. Every request.url_for() link is wrong in the same way, and your logs show the proxy's IP instead of the visitor's. Set FORWARDED_ALLOW_IPS="*" (or --forwarded-allow-ips="*" on the command) and the same request returns location: https://api.example.com/orders, with the real client IP.

Only trust * when the proxy is the only thing that can reach the app. On a managed platform, other containers in your project may still reach the app, so check what can. On a VPS, the 127.0.0.1:8000 port binding above makes it true. If you'd rather name the proxy, list its IP: FORWARDED_ALLOW_IPS="10.0.0.5". Inside a container, Nginx's requests don't come from 127.0.0.1, so the default list ignores them.

On your own VPS: Nginx

If you run the stack on a VPS, put Nginx on the host. This server block redirects HTTP, sets HSTS on the HTTPS side (browsers ignore it over plain HTTP), forwards the right headers and keeps WebSocket upgrades working:

map $http_upgrade $connection_upgrade { default upgrade; '' ''; } upstream shop_api { server 127.0.0.1:8000; keepalive 16; } server { listen 80; server_name api.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; add_header Strict-Transport-Security "max-age=31536000" always; client_max_body_size 10m; location / { proxy_pass http://shop_api; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 60s; } } 

Get the certificate with Certbot for your OS, then run sudo nginx -t before reloading. The map block matters: many guides set Connection "upgrade" on every request, which breaks keep-alive to the upstream. This version sends it only for real WebSocket requests.

How many workers?

The FastAPI docs recommend one process per container when something else (a platform, Kubernetes, Compose replicas) runs several containers for you. That's the setup above. Use --workers N only when one container is all you have.

Before you add workers, check the memory. Typical numbers for this app on Linux:

Command Processes Memory (RSS)
fastapi run app/main.py 1 about 102 MB
fastapi run app/main.py --workers 2 parent + helper + 2 workers about 320 MB (96 + 14 + 101 + 108)

The parent process imports your app too, so going from 1 to 2 workers triples memory. On a 512 MB container, one process leaves plenty of headroom; two is the limit. Your app's imports decide the real numbers, so measure with docker stats.

CPU is the other half. A worker only helps when the container has a whole CPU to give it. On 0.5 vCPU, a second worker just competes for the same slice. Add replicas instead.

Deploy it on Hubfly space

Hubfly space Cell runs containers straight from your Git repository, and a root Dockerfile is used as-is. That's the file from Step 4, so nothing changes. The platform's edge handles HTTPS, so you skip Nginx and Certbot.

  1. Create a project, pick a region, and set a monthly budget cap to stop spending past a set amount.
  2. Add Postgres. Click Create Container → Docker image → postgres:18. Choose the Dedicated Tier at 0.5 vCPU and 512 MB, and turn Auto Sleep off. Under Environment Variables, add POSTGRES_USER=shop, POSTGRES_DB=shop and POSTGRES_PASSWORD (keep Secret on). Under Storage, add a 10 GB volume (keep the Balanced performance mode) mounted at /var/lib/postgresql. On Review, copy the Private Network DNS name.
  3. Add the API. Click Create Container → Git repository and pick your repo. Leave Automatically detect the build on; the root Dockerfile wins. Choose Dedicated 0.5 vCPU / 512 MB with Auto Sleep off. Add DATABASE_URL=postgresql+psycopg://shop:<password>@<private-dns-name>:5432/shop (Secret on).
  4. Run migrations on every start. Do this in the wizard's Configuration step, before the first deploy. In Process Settings → Command, enter one argument per line: sh, then -c, then alembic upgrade head && exec fastapi run app/main.py --port 8000. Set the working directory to /code if it isn't already, because Alembic finds alembic.ini from there. With a single container this is safe: the migration runs before the app starts, a failed migration stops the container instead of serving new code on the old schema, and exec keeps the clean shutdown from Step 4. When you add replicas, move the migration into a separate job (a Compose stack on Hubfly space imports a restart: "no" service as a Job).
  5. Add the health check. In Configuration → Health Check, paste the same check in Docker's shell form: CMD-SHELL python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)".
  6. Get a URL. Open Network → Add Domain → Auto Endpoint. The port scan suggests 8000. TLS and Force HTTPS are on by default. Attach your own domain from the same screen when you're ready.
  7. Check the scheme. Open https://<your-endpoint>/orders/. If the redirect points to http://, add FORWARDED_ALLOW_IPS=* to the API container and restart it. Every container in your project can reach this one on the private network, so keep workloads you don't trust in a separate project.

What it costs, at Hubfly space's rates ($0.004 per vCPU-hour, $0.002 per GB-hour of RAM, 730 hours a month):

Item Math Per month
API, 0.5 vCPU / 512 MB, always on 0.5 × 0.004 × 730 + 0.5 × 0.002 × 730 + 4 GB disk $2.23
Postgres, same size same $2.23
10 GB Balanced volume 10 × 0.00001536 × 1.5 × 730 $0.17
Total about $4.64

There's no fee per project, seat or custom domain. Want to try it first? The shared tier is free for 360 hours a month for both containers together (0.3 vCPU / 256 MB, sleeps when idle), and the single-process app fits in its 256 MB.

Where something else wins: if you already run Kubernetes, keep FastAPI there with one process per pod. If your API gets a few requests a day, a serverless function costs less than any always-on container.

What can go wrong

Symptom Cause Fix
Redirects or url_for links use http:// on an HTTPS site Proxy headers not trusted Set FORWARDED_ALLOW_IPS to the proxy IP, or * when only the proxy can reach the app
Logs show the proxy's IP for every visitor Same as above Same fix
502 Bad Gateway from the proxy App listening on 127.0.0.1 inside the container fastapi run binds 0.0.0.0 by default; don't pass --host 127.0.0.1 in a container (the 502 fix)
docker stop takes 10 s, exit code 137 Shell-form CMD, sh swallows SIGTERM Exec-form CMD, or exec in front of the last command
ValueError: invalid interpolation syntax from Alembic % in an encoded password .replace("%", "%%") in env.py
could not translate host name @, # or / unencoded in the password URL-encode the password in DATABASE_URL
Postgres 18 container keeps restarting Volume at /var/lib/postgresql/data Mount /var/lib/postgresql
/openapi.json still public Only docs_url turned off openapi_url=None
Container killed under load Too many workers for the memory One process per container, more replicas

FAQ

Should I use Gunicorn with FastAPI?

Not any more. Gunicorn was used to restart crashed Uvicorn workers. fastapi run --workers N and uvicorn --workers N do that themselves now, and FastAPI's official guide to server workers uses only those two commands. The old uvicorn.workers module is deprecated in favour of the separate uvicorn-worker package. Use Gunicorn only if your team already depends on its hooks.

What's the difference between fastapi run and uvicorn?

fastapi run is a thin wrapper around Uvicorn. It finds your app object, binds 0.0.0.0:8000, turns reload off and enables proxy headers. uvicorn app.main:app --host 0.0.0.0 --port 8000 gives the same server. Pick one and use it everywhere.

How many Uvicorn workers do I need?

One per container when a platform or orchestrator runs replicas. On a single server, start with one worker per full CPU core and confirm the memory fits: each extra worker here added about 100 MB.

Is Uvicorn production ready?

Yes. It's the server FastAPI's own docs use in production. Put a TLS-terminating proxy or platform edge in front of it and trust its headers as in Step 6.

How do I disable the docs in production?

Pass openapi_url=None to FastAPI(). That turns off /docs, /redoc and /openapi.json together. Use an environment variable, as in Step 1, so staging can keep them.

Why does my FastAPI app redirect to HTTP behind Nginx?

The app doesn't trust the X-Forwarded-Proto header from Nginx, so it thinks the request was plain HTTP. Send the header from Nginx and set FORWARDED_ALLOW_IPS (Step 6).

Where should database migrations run?

Before the new code serves requests: a Compose job gated with service_completed_successfully, a CI step, or, with a single container, a start command that runs alembic upgrade head and then execs the server. Not inside every worker's startup. This post shows the job pattern in detail.

Should I use async def or def for endpoints?

Use async def when everything you await is async (an async database driver, httpx.AsyncClient). Use def for blocking libraries like the synchronous SQLAlchemy session here. FastAPI runs def endpoints in a thread pool, so they don't block the server.

Go further on Hubfly space

  • Skip running Postgres yourself: a managed PostgreSQL database starts at about $2.02 a month (docs).
  • Deploy on every push: trigger a redeploy from GitHub Actions with a deploy webhook (docs).
  • Scale out: replicate the API container behind a load balancer when one process isn't enough (docs).

Try it on Hubfly space

By Octave · drafted with AI, reviewed by Octave · Last updated 9 November 2026

Originally published on the Hubfly space Journal.