# How to Deploy FastAPI to Production (Step-by-Step Guide)

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**](https://hubfly.space/?ref=how-to-deploy-fastapi-to-production): 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:

```txt
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:

```py
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`:

```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.

```py
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`:

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

```bash
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:

```py
# "%" 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:

```py
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:

```txt
sqlalchemy.url =
```

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

```bash
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

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

```txt
.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`:

```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):

```txt
POSTGRES_PASSWORD=change-me-long-random
```

Start it and check it:

```bash
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:

```txt
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:

```nginx
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](https://certbot.eff.org/) 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](https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&utm_campaign=how-to-deploy-fastapi-to-production)) |
| `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 `exec`s the server. Not inside every worker's startup. [This post shows the job pattern in detail](https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&utm_campaign=how-to-deploy-fastapi-to-production).

### 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](https://blog.hubfly.space/managed-postgresql-database-2-dollars-a-month?utm_source=hashnode&utm_campaign=how-to-deploy-fastapi-to-production) starts at about $2.02 a month ([docs](https://www.hubfly.space/docs/databases/get-started/?ref=how-to-deploy-fastapi-to-production)).
- **Deploy on every push:** trigger a redeploy from GitHub Actions with a [deploy webhook](https://blog.hubfly.space/deploy-on-push-github-actions-webhook?utm_source=hashnode&utm_campaign=how-to-deploy-fastapi-to-production) ([docs](https://www.hubfly.space/docs/deployment-pipelines/?ref=how-to-deploy-fastapi-to-production)).
- **Scale out:** replicate the API container behind a load balancer when one process isn't enough ([docs](https://www.hubfly.space/docs/load-balancers/?ref=how-to-deploy-fastapi-to-production)).

[**Try it on Hubfly space**](https://hubfly.space/?ref=how-to-deploy-fastapi-to-production)

---

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

*Originally published on the [Hubfly space Journal](https://blog.hubfly.space/how-to-deploy-fastapi-to-production?utm_source=hashnode&utm_campaign=how-to-deploy-fastapi-to-production).*

