Deployment

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, Starlette 1.7 and Uvicorn 0.54, checked on 2026-10-02 (FastAPI CLI 0.0.32, Gunicorn 26.2, uvicorn-worker 0.4). The Python examples and the server commands were executed on Python 3.13: the servers were started for real (fastapi run, uvicorn --workers, Gunicorn, Hypercorn, Daphne and Granian), exercised with curl, and stopped with signals; the outputs shown come from those runs. Examples that need a domain name, a Docker daemon or a FastAPI Cloud account (Traefik with Let’s Encrypt, the Compose file, fastapi deploy) could not be run in the verification environment and are marked as such. To deploy the Bookshelf API is to make it available to its users: on a machine that stays up, behind HTTPS, restarted when it crashes, able to use all the CPU cores, with its database schema ready. None of that is FastAPI-specific code; it is a set of decisions about what runs around your app, and this page walks through them in the order the official documentation does, then shows the commands.

Deployment Strategies at a Glance

There is no single recipe. The official guide lists three families, and everything on this page is a way of combining a few components:

Strategy You run The platform runs

Your own server (a VM or bare metal)

A TLS proxy, the ASGI server (under a process manager), restarts, certificates, upgrades

Nothing: you own all six concerns

Containers (Docker, Compose, Kubernetes, a managed container service)

One image with one Uvicorn process; cluster configuration

Replication, restarts, rolling updates, usually HTTPS and load balancing

A platform that hosts FastAPI itself (FastAPI Cloud, other PaaS)

fastapi deploy or a Git push

HTTPS, replication with autoscaling, restarts

The picture below is the topology the rest of the page builds towards, and the one the container guide in FastAPI in Docker & Kubernetes implements: a single TLS proxy on port 443, a variable number of identical containers each running one Uvicorn process, a database, and a pre-start job that runs the migrations once.

A diagram, left to right: clients connect over HTTPS to a TLS proxy (Traefik, Caddy or Nginx, which handles certificates and renewal and sets the forwarded headers, and is the only component on port 443). The proxy forwards plain HTTP, balanced, to Container 1, Container 2 and a dashed Container N, each running one fastapi run Uvicorn process. The containers talk SQL to a PostgreSQL database on the right. A dashed pre-start job at the bottom, running migrations once, has a dashed arrow to the database and runs before the containers start

The Six Deployment Concepts

The official concepts page names six things to decide, and they are a better checklist than any tool list. For each one this section gives the question, the usual answer, and what FastAPI itself contributes (often: nothing, which is the point).

Concept Question Typical tools

Security - HTTPS

Who terminates TLS, who obtains and renews the certificates?

Traefik and Caddy (renew automatically), Nginx or HAProxy with Certbot, Kubernetes ingress with cert-manager, a cloud load balancer

Running on startup

What starts the server when the machine boots?

Docker, Docker Compose, Docker Swarm, Kubernetes, systemd, Supervisor, a cloud service

Restarts

What brings it back after a crash?

The same tool that starts it

Replication (processes)

How many processes serve requests, and who listens on the port?

--workers, several containers behind a load balancer, a cloud service

Memory

How much RAM does each process hold, and how many fit?

Measurement (htop, monitoring), container limits

Previous steps before starting

Who runs migrations or other one-time steps, exactly once?

An init container, a start script, a release job

HTTPS

HTTPS is plain HTTP inside a TLS connection. The encryption happens at the TCP level, before the HTTP request and the domain name are known, which is why only one process per IP address and port (443) can own the certificates; the TLS SNI extension lets that one process hold a certificate per domain. The usual design is therefore a TLS termination proxy in front of the application: it decrypts the request, forwards plain HTTP to Uvicorn and encrypts the response. A separate component, or the same proxy, renews the certificates before they expire (Let’s Encrypt certificates live about three months). The details, including Traefik and Let’s Encrypt, are in HTTPS.

Running on startup and restarts

Starting fastapi run over SSH works until the connection drops or the machine reboots. A supervisor outside of your Python process has to start the server at boot and start it again when it exits. FastAPI contains an error to the request that raised it (the client gets a 500 and the process carries on), so the case to handle is the crash of the whole process: a segmentation fault in a C extension, the out-of-memory killer, a bug in startup code. By then nothing inside the same process can help, so the restarter must be external. On a plain Linux host that is typically systemd; in containers it is the restart policy of Docker or Kubernetes.

# /etc/systemd/system/bookshelf-api.service
[Unit]
Description=Bookshelf API (FastAPI)
After=network.target

[Service]
User=bookshelf
WorkingDirectory=/srv/bookshelf
EnvironmentFile=/etc/bookshelf/api.env
ExecStartPre=/srv/bookshelf/.venv/bin/python prestart.py
ExecStart=/srv/bookshelf/.venv/bin/fastapi run main.py --port 8000 --workers 2
Restart=on-failure
RestartSec=2
TimeoutStopSec=40

[Install]
WantedBy=multi-user.target

Restart=on-failure restarts the service when the process exits with an error, is killed by a signal or times out, ExecStartPre runs the pre-start step before every start, and TimeoutStopSec should be a little larger than the server’s graceful-shutdown timeout (see Graceful Shutdown and Health). This unit file is a sketch assembled from the documented systemd directives (see the systemd link in the references) and was not run for this page. Do not restart forever blindly: an app that crashes immediately on every start is a bug to find, which is what the restart-rate limits of the supervisor are for.

Replication and the port

Several worker processes can serve the same API, but only one process can listen on a given IP address and port. So there is always one component that owns the port and hands connections to the others: a Uvicorn parent process (--workers), a Gunicorn master, a load balancer in front of several containers, or a cloud service. The next sections show each.

Memory

Processes do not share memory. Everything your app loads at startup (a cache, a machine-learning model, a large file) is loaded once per process. The official example: a 1 GB model costs 1 GB with one process and 4 GB with four workers, which is a problem on a 3 GB machine. The lifespan function of the Bookshelf API below makes that visible, because it runs once per worker process and stores a "catalog" on app.state; each worker holds its own copy and the response reports the process ID that served it.

import logging
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI

logger = logging.getLogger("uvicorn.error")


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once PER WORKER PROCESS, not once per deployment.
    logger.info("lifespan startup in pid %s", os.getpid())
    app.state.catalog = {1: "Dune", 2: "Emma"}  # per-process memory
    yield
    logger.info("lifespan shutdown in pid %s", os.getpid())


app = FastAPI(title="Bookshelf API", lifespan=lifespan)


@app.get("/healthz")
async def healthz() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/books/{book_id}")
async def read_book(book_id: int) -> dict[str, int | str]:
    return {"id": book_id, "title": app.state.catalog.get(book_id, "unknown"), "pid": os.getpid()}


@app.get("/env")
async def env() -> dict[str, str | None]:
    return {"FASTAPI_ENV": os.environ.get("FASTAPI_ENV")}

This file, main.py, is the application used by the commands in the rest of the page. Startup and shutdown work such as opening connection pools belongs in lifespan (see Settings, Lifespan & Background Tasks); the consequence for deployment is that with N workers it runs N times and every pool is multiplied by N. Size database pools as connections per process times processes times replicas, and compare the result with the database’s connection limit (see SQL Databases with SQLModel).

A rule of thumb from the official concepts page is to aim for roughly 50 to 90 percent utilisation of CPU and memory: well below that you pay for idle machines, at 100 percent a spike pushes the server into swap or an out-of-memory kill. Measure with htop or a monitoring stack rather than guessing.

Previous steps before starting

Some steps must happen once per release, before the first request: database migrations, creating a schema, warming a cache. If every worker or replica ran them, they would duplicate the work or, for a migration, conflict with each other. So run them in a single process before the servers start. The simplest portable form is a start script that runs the step and then replaces itself with the server using exec, so that the server becomes the main process (and receives the signals):

"""Pre-start step: runs exactly once, before any server process exists."""
import sqlite3
import sys

DB = "bookshelf.db"


def migrate() -> None:
    with sqlite3.connect(DB) as con:
        con.execute("CREATE TABLE IF NOT EXISTS book (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
        con.execute("INSERT OR IGNORE INTO book (id, title) VALUES (1, 'Dune')")
    print("pre-start: schema ready", file=sys.stderr)


if __name__ == "__main__":
    migrate()
#!/bin/sh
set -eu
python prestart.py          # once; a failure stops the script (set -e)
exec fastapi run main.py --port "${PORT:-8000}" --workers "${WEB_CONCURRENCY:-2}"

Running PORT=8213 ./start.sh printed pre-start: schema ready, then the server banner, answered /healthz and left bookshelf.db behind. A second script whose pre-start command exited with status 3 stopped immediately with exit code 3 and never started a server, which is the behaviour you want from a failed migration. A real project would call Alembic here (alembic upgrade head); in Kubernetes the same role is played by an init container or a Job, covered in FastAPI in Docker & Kubernetes. With several replicas a plain script is not enough, because every replica would run it: use a migration job or a lock.

fastapi run

Since FastAPI 0.111.0 (2024-05-03) the fastapi command is the front door to the server. It comes with fastapi[standard] (the plain fastapi package does not install it, a change made in 0.112.0), wraps Uvicorn, and finds the app for you. There are two modes:

fastapi dev fastapi run

Purpose

Local development

Production

Auto-reload

On by default (--reload), resource-hungry and less stable

Off by default

Listens on

127.0.0.1 (your machine only)

0.0.0.0 (all interfaces, so reachable by anyone who can reach the machine)

FASTAPI_ENV

Set to development if not already set

Left unchanged: set it yourself if the app needs to detect production

Reload directories

--reload-dir

Not available

The options of fastapi run (from fastapi run --help on FastAPI CLI 0.0.32) are:

Option Meaning

path (argument)

A Python file or package directory. Without it the CLI tries main.py, app.py, api.py, app/main.py, app/app.py, app/api.py.

--host

Interface to bind. Default 0.0.0.0.

--port

Port to bind. Default 8000; also read from the PORT environment variable.

--reload / --no-reload

Auto-reload. Default off; never use it in production.

--workers <int>

Number of worker processes. Mutually exclusive with --reload.

--root-path <str>

The path prefix a proxy adds, see Middleware, CORS & Proxies.

--app <str>

Name of the variable holding the app in the imported module (otherwise it looks for app, then api, then the first FastAPI instance).

--entrypoint, -e <str>

An import string such as backend.main:app.

--proxy-headers / --no-proxy-headers

Interpret X-Forwarded-Proto, X-Forwarded-For and X-Forwarded-Port. Default enabled.

--forwarded-allow-ips <str>

Comma-separated IPs trusted to send those headers; * means trust everything.

--verbose, -v

Show how the app was discovered and imported.

Running it and calling the app from curl gave:

$ fastapi run main.py --port 8201
$ curl -s localhost:8201/books/1
{"id":1,"title":"Dune","pid":22609}
 ⚡️ Starting FastAPI in production mode

 🐍 Using import string: main:app

 🌐 Server started at http://0.0.0.0:8201
    Documentation at http://0.0.0.0:8201/docs

  Logs:

INFO:     Started server process [22609]
INFO:     Waiting for application startup.
INFO:     lifespan startup in pid 22609
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8201 (Press CTRL+C to quit)
INFO:     127.0.0.1:60352 - "GET /books/1 HTTP/1.1" 200 OK
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     lifespan shutdown in pid 22609
INFO:     Application shutdown complete.
INFO:     Finished server process [22609]

Declaring the entrypoint once

Passing a path to every command is easy to forget, and other tools (an editor extension, FastAPI Cloud) cannot guess it. The recommended way is to declare the import string once in pyproject.toml:

[project]
name = "bookshelf"
version = "0.1.0"

[tool.fastapi]
entrypoint = "backend.main:app"

With the app in backend/main.py and that file in the working directory, plain fastapi run --port 8208 printed Using import string: backend.main:app and served the app, with no path argument.

Telling the app which environment it is in

fastapi dev sets the environment variable FASTAPI_ENV to development before importing your app (keeping a value you already set). The documented conventional values are development and production. fastapi run does not set it at all, so set it in the unit file, the container or the platform when the app has to behave differently. The /env endpoint of main.py returned {"FASTAPI_ENV":"development"} under fastapi dev, null under a plain fastapi run, and {"FASTAPI_ENV":"production"} with FASTAPI_ENV=production fastapi run main.py. Configuration in general, such as database URLs and secrets read from the environment, is covered in Settings, Lifespan & Background Tasks.

ASGI Servers

FastAPI is an ASGI framework, so it needs an ASGI server to listen on sockets and speak HTTP. fastapi run uses Uvicorn, but any of the following can serve the same app:

Server Character Command used to serve main:app (executed)

Uvicorn

The default; a high-performance ASGI server. uvicorn[standard] adds uvloop, httptools and friends.

uvicorn main:app --host 0.0.0.0 --port 8000

Hypercorn

ASGI server with HTTP/2 and Trio support

hypercorn main:app --bind 127.0.0.1:8301

Daphne

The ASGI server built for Django Channels

daphne -b 127.0.0.1 -p 8302 main:app

Granian

A Rust HTTP server for Python applications

granian --interface asgi --host 127.0.0.1 --port 8303 main:app

Each of these servers answered /healthz with {"status":"ok"} on Hypercorn 0.18, Daphne 4.2 and Granian 2.8 (in a separate virtual environment from the Uvicorn runs). The first argument is an import string, main:app, meaning “from main import app”. To install Uvicorn yourself, add uvicorn[standard] to the project (it is already included in fastapi[standard]). Never run any of them with --reload in production: reloading watches the file system, consumes far more resources and is less stable.

Uvicorn options that matter in production

Everything the CLI does can be done with uvicorn directly, which also exposes more settings. Uvicorn can be configured through the command line, through keyword arguments of uvicorn.run(), or through environment variables with the prefix UVICORN_ (command line and arguments win). The production-relevant options are:

Option Meaning

--host, --port, --uds, --fd

Where to listen. Default host is 127.0.0.1 (the fastapi run default is 0.0.0.0). --uds /path/app.sock binds a UNIX domain socket, useful behind a local proxy; --fd takes a socket from a process manager.

--workers

Number of worker processes. Default: the WEB_CONCURRENCY environment variable, or 1. Not valid with --reload.

--proxy-headers, --forwarded-allow-ips

Trust forwarded headers. Default trusts only 127.0.0.1 and ::1, or the environment variable FORWARDED_ALLOW_IPS.

--root-path

The ASGI root_path, for apps served below a prefix.

--ssl-keyfile, --ssl-certfile, --ssl-ca-certs, --ssl-ciphers, …​

Terminate TLS in Uvicorn itself, see TLS in Uvicorn itself.

--limit-concurrency

Maximum concurrent connections or tasks; beyond it Uvicorn answers 503 immediately (requests are not queued).

--limit-max-requests, --limit-max-requests-jitter

Restart a worker after that many requests (plus random jitter), a pragmatic guard against slow memory leaks under a process manager.

--backlog

Size of the TCP accept queue (OS-level, default 2048).

--timeout-keep-alive

Close idle keep-alive connections after this many seconds (default 5).

--timeout-graceful-shutdown

Maximum seconds to wait for in-flight requests on shutdown.

--timeout-worker-healthcheck

Seconds to wait for a worker to answer a health check (default 5).

--log-level, --no-access-log, --log-config

Logging verbosity, access log and a logging configuration file.

--loop, --http, --ws, --lifespan

Choose implementations (auto, uvloop, httptools, h11, …​). auto picks the fast ones when installed.

fastapi run forwards only the options listed in the table of the previous section; to use the others, call uvicorn (or put the application in a script that calls uvicorn.run).

import uvicorn

if __name__ == "__main__":
    # Import string (not the app object) is required for workers > 1 and reload.
    uvicorn.run("main:app", host="0.0.0.0", port=8215, workers=2, log_level="info")

Running python serve.py started two server processes and served /books/1. Pass an import string rather than the app object whenever workers or reload is used, and keep the call inside the if name == "main" guard, because worker processes are started by spawning and re-import the module.

Workers

One Uvicorn process serves many concurrent clients, but only on one CPU core: Python code in one process does not run in parallel (see Async & Concurrency). To use several cores on one machine, run several worker processes behind the single listening socket.

flowchart TB client([Clients]) subgraph M["Parent process (manager) - owns the port, monitors and restarts workers"] direction TB W1["Worker 1 - own memory, own lifespan, own DB pool"] W2["Worker 2 - own memory, own lifespan, own DB pool"] W3["Worker 3 - own memory, own lifespan, own DB pool"] end client -->|":8000"| M M --> W1 M --> W2 M --> W3

fastapi run --workers and uvicorn --workers

Both commands take --workers. The count can also come from the WEB_CONCURRENCY environment variable, which Uvicorn reads when --workers is not given.

fastapi run main.py --port 8202 --workers 3
uvicorn main:app --host 0.0.0.0 --port 8080 --workers 4
WEB_CONCURRENCY=2 uvicorn main:app --host 127.0.0.1 --port 8203

The log of the first command shows the structure: one parent and three worker processes, each running its own lifespan.

INFO:     Uvicorn running on http://0.0.0.0:8202 (Press CTRL+C to quit)
INFO:     Started parent process [22642]
INFO:     Started server process [22653]
INFO:     lifespan startup in pid 22653
INFO:     Started server process [22652]
INFO:     lifespan startup in pid 22652
INFO:     Started server process [22651]
INFO:     lifespan startup in pid 22651

Eight requests to /books/2 were answered by two of the three process IDs (22651 and 22652); the connections were not shared evenly, which is normal when the requests are short and sequential. Do not expect round-robin. The kernel hands each new connection to whichever worker accepts it, so distribution evens out only under real concurrent load.

On shutdown (SIGTERM) the parent terminated each child, each worker ran its own shutdown lifespan, and the parent stopped last (Terminated child process, lifespan shutdown in pid …​, Stopping parent process).

What the Uvicorn process manager does for you

The built-in manager monitors its children. Uvicorn starts workers by spawning (not forking), so it works on Windows too, and:

  • It restarts workers that die. With two workers, a kill -9 on one of them produced Child process [22979] died in the parent’s log, and a new server process [23036] started and served the following requests.

  • It restarts workers that hang. A child that stops answering its health check (--timeout-worker-healthcheck) is killed and replaced.

  • It reacts to signals sent to the parent (not on Windows). SIGHUP restarts the workers one at a time without dropping requests, so new code on disk is picked up (a run printed Received SIGHUP, restarting processes. and every later request was served by a new process); SIGTTIN adds one worker and SIGTTOU removes one.

kill -HUP "$(cat bookshelf.pid)"    # rolling restart of the workers
kill -TTIN "$(cat bookshelf.pid)"   # one more worker
kill -TTOU "$(cat bookshelf.pid)"   # one fewer worker

The SIGHUP form was run; SIGTTIN and SIGTTOU are taken from the Uvicorn documentation and were not run. With this built-in manager the workers cover replication and, partly, restarts. Starting at boot, HTTPS, memory and pre-start steps are still yours (The Six Deployment Concepts), and nothing restarts the parent if it dies, so the whole thing still needs systemd or a container restart policy around it.

Gunicorn and the UvicornWorker deprecation

For years the standard production setup was Gunicorn, a mature pre-fork process manager, running Uvicorn workers, and an official Docker image was built around it. Two things changed:

  • Uvicorn gained its own process manager (the --workers option above, which restarts dead workers), removing the main reason to add Gunicorn. FastAPI’s container guide now states that the old tiangolo/uvicorn-gunicorn-fastapi image is deprecated and tells you to use --workers instead.

  • The worker classes that shipped inside Uvicorn are deprecated. The Uvicorn documentation says the uvicorn.workers module is deprecated and will be removed in a future release, and points to the separate uvicorn-worker package. Verified on Uvicorn 0.54.0: importing uvicorn.workers still works but emits a DeprecationWarning that reads: The uvicorn.workers module is deprecated. Please use uvicorn-worker package instead.

So Gunicorn with Uvicorn workers is not dead; only the old import path is. If you want Gunicorn (graceful rolling upgrades through Gunicorn’s own signals, its configuration file, or to reuse existing operational tooling), install the new package and change the worker class to uvicorn_worker.UvicornWorker (or uvicorn_worker.UvicornH11Worker on PyPy):

pip install gunicorn uvicorn-worker
gunicorn main:app -w 2 -k uvicorn_worker.UvicornWorker -b 127.0.0.1:8204
import os

bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
worker_class = "uvicorn_worker.UvicornWorker"
graceful_timeout = 30
timeout = 30

Source: Gunicorn

Saved as gunicorn.conf.py next to main.py, which Gunicorn picks up automatically, PORT=8216 gunicorn main:app started the master and two workers (log lines Using worker: uvicorn_worker.UvicornWorker and Booting worker with pid: …​) and served /books/2. Differences to keep in mind:

  • Gunicorn has its own options, so some Uvicorn flags such as --limit-concurrency are not available through it. To pass Uvicorn configuration to the worker, subclass the worker and set its CONFIG_KWARGS dictionary.

  • Certificates can be given to Gunicorn directly with --keyfile and --certfile.

  • uvicorn.workers.UvicornWorker still ran in the test environment, but plan to migrate: it is documented as to be removed.

How many workers, and when not to use them

The official guidance is to use --workers when you run on a single machine (a VM) and want its cores used, and not to use them in a cluster. On Kubernetes, Docker Swarm, Nomad or a managed container service the platform already replicates and restarts containers, so a second process manager inside each container only adds complexity. There the rule is one Uvicorn process per container, several containers (the topology figure above). Practical points for choosing the number:

  • Start with the number of CPU cores available to the process (a container limited to one CPU gains nothing from four workers) and measure. I/O-bound async def apps gain less from extra workers than CPU-bound ones, because one process already overlaps many waits.

  • Multiply memory by the worker count (Memory) and database connections by the worker count.

  • In-process state is per worker: a Python dict used as a cache or a rate-limit counter is not shared. Use the database, Redis or another shared store for anything that must be consistent across requests.

  • Workers are processes, not threads: they do not help with a blocking call inside an async def handler in one process (see Async & Concurrency); they only add parallelism.

  • A background task or a schedule started inside the app runs once per worker. A job that must run once per deployment belongs in a separate process or a platform scheduler.

Graceful Shutdown and Health

A deployment replaces processes constantly: new releases, scale-down, node maintenance. The server receives SIGTERM (or SIGINT) and should stop accepting connections, let in-flight requests finish, run the shutdown part of the lifespan, and exit. Uvicorn does exactly that, bounded by --timeout-graceful-shutdown.

import asyncio

from fastapi import FastAPI

app = FastAPI(title="Bookshelf API")


@app.get("/slow")
async def slow(seconds: float = 3) -> dict[str, float]:
    await asyncio.sleep(seconds)
    return {"slept": seconds}

With uvicorn slow:app --timeout-graceful-shutdown 10, a request for /slow?seconds=3 was in flight when SIGTERM arrived one second after it started. The server logged Shutting down and Waiting for connections to close, the client still received {"slept":3.0} with status 200, and only then did Waiting for application shutdown run. With --timeout-graceful-shutdown 1 and a five-second request the server logged Cancel 1 running task(s), timeout graceful shutdown exceeded, the handler received a CancelledError, and the client got 500 Internal Server Error. Set the timeout above your slowest normal request, and set the platform’s own termination grace period (TimeoutStopSec in systemd, the pod’s grace period in Kubernetes) above Uvicorn’s.

To protect a replica from overload, --limit-concurrency makes Uvicorn answer 503 immediately instead of queueing without bound. A run with uvicorn slow:app --limit-concurrency 2 and four simultaneous two-second requests returned 503 to three of them and 200 to one. Choose the limit from a load test (see Property-Based & Load Testing) and let the load balancer retry or shed. A health endpoint such as the /healthz of main.py is what a load balancer, a Docker health check or a Kubernetes probe polls; probes and graceful termination in Kubernetes are covered in Health Probes & Lifecycle.

A test that the app starts, runs its lifespan and answers is cheap insurance before every deployment:

from fastapi.testclient import TestClient

from main import app


def test_health_and_per_process_state() -> None:
    with TestClient(app) as client:  # runs lifespan startup/shutdown
        assert client.get("/healthz").json() == {"status": "ok"}
        body = client.get("/books/1").json()
        assert body["title"] == "Dune"
        assert isinstance(body["pid"], int)

This passed (1 passed) with pytest. Using TestClient as a context manager is what triggers the lifespan; without with, the startup code never runs.

HTTPS

HTTPS is not a switch you turn on in FastAPI; it is a property of the whole path from the browser to the server, and the application normally never sees it.

The TLS termination proxy

The proxy listens on port 443, holds the certificates (one per domain, selected with SNI), decrypts the request, and sends plain HTTP to the application on a private network or the same host. The responses take the reverse path. The proxy is also the natural place for HTTP to HTTPS redirects, rate limits, compression and static files. Candidates:

  • Traefik and Caddy obtain and renew certificates by themselves.

  • Nginx and HAProxy need an external renewal program such as Certbot.

  • Kubernetes ingress controllers use cert-manager.

  • A cloud load balancer or platform (Azure Front Door, AWS CloudFront and load balancers, Google Cloud load balancing, FastAPI Cloud) terminates TLS for you, and may charge or restrict.

A certificate is bound to a domain name, not to an IP address. To get one, you create a DNS record pointing the domain to your server and then prove control of the domain to the certificate authority.

Let’s Encrypt and renewal

Let’s Encrypt is a free, automated certificate authority run as a project of the Linux Foundation. Its certificates are short-lived (about three months) so that renewal has to be automatic. Renewal proves domain control in one of two popular ways: by changing a DNS record (which requires the program to support your DNS provider’s API), or by serving a challenge on the public IP address, port 80 or 443. The second way is one reason why one component should own both TLS and renewal: if the renewal program and the proxy both need the port, you would have to stop the proxy during renewal, and your apps would be down.

Traefik with Let’s Encrypt

Traefik reads routing rules from labels on Docker containers (the Docker provider) and has built-in ACME support for Let’s Encrypt. The Compose file below puts the Bookshelf API behind it: Traefik owns ports 80 and 443, redirects HTTP to HTTPS, requests a certificate for the domain in the router rule with the HTTP challenge, and keeps the certificates in a volume.

services:
  traefik:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entryPoints.web.address=:80
      - --entryPoints.websecure.address=:443
      - --entryPoints.web.http.redirections.entryPoint.to=websecure
      - --entryPoints.web.http.redirections.entryPoint.scheme=https
      - --certificatesresolvers.letsencrypt.acme.email=admin@bookshelf.example
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt

  api:
    build: .
    command: ["fastapi", "run", "app/main.py", "--port", "8000", "--forwarded-allow-ips", "*"]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.bookshelf-api.rule=Host(`bookshelf.example`)"
      - "traefik.http.routers.bookshelf-api.entrypoints=websecure"
      - "traefik.http.routers.bookshelf-api.tls.certresolver=letsencrypt"
      - "traefik.http.services.bookshelf-api.loadbalancer.server.port=8000"

volumes:
  letsencrypt:

This file was assembled from the documented Traefik options (the Docker provider flags and labels, the entry-point redirection, and the ACME resolver with the HTTP challenge) and was not run: it needs a public domain and a Docker daemon, and neither was available. Check three things before using it. The image tag should be the Traefik version you tested (traefik:v3 is a moving tag), the e-mail is a placeholder, and acme.json must stay on a persistent volume or you will request new certificates on every start and hit the certificate authority’s rate limits. Mounting the Docker socket gives Traefik broad control of the host, so treat it as a sensitive component. "*" for --forwarded-allow-ips is acceptable here only because the api container publishes no port and can be reached only from Traefik through the Compose network. The experiment with a path prefix (StripPrefix and --root-path) is in Middleware, CORS & Proxies, and Compose in general in Compose in Practice.

Forwarded headers: telling the app about HTTPS

Behind the proxy, Uvicorn talks plain HTTP and knows nothing about TLS. The proxy therefore adds X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host (Uvicorn itself reads the first two), and the server must be told which connecting addresses it may believe. Without trust the application thinks it is on http at the proxy’s address, so request.url, redirects and generated links are wrong. The Bookshelf API can print what it believes:

from fastapi import FastAPI, Request

app = FastAPI(title="Bookshelf API")


@app.get("/whoami")
async def whoami(request: Request) -> dict[str, str | None]:
    return {
        "scheme": request.url.scheme,
        "client": request.client.host if request.client else None,
        "url": str(request.url),
    }

Three servers were started with different trust and called with the headers a proxy would send:

fastapi run proxied.py --port 8210                                  # default trust
fastapi run proxied.py --port 8211 --forwarded-allow-ips 127.0.0.1  # trust the caller
fastapi run proxied.py --port 8212 --forwarded-allow-ips 192.0.2.1  # trust someone else
curl -s -H "X-Forwarded-Proto: https" -H "X-Forwarded-For: 203.0.113.7" \
     -H "Host: api.bookshelf.example" localhost:8210/whoami
{"scheme":"https","client":"203.0.113.7","url":"https://api.bookshelf.example/whoami"}

The first two servers returned the response above (the default already trusts 127.0.0.1, the address curl came from); the third, which trusts only 192.0.2.1, ignored the headers and returned {"scheme":"http","client":"127.0.0.1","url":"http://api.bookshelf.example/whoami"}. In production set --forwarded-allow-ips to the proxy’s address or network; use "*" only when nothing but the proxy can reach the port, because otherwise any client can forge its address and scheme. Using several headers, several proxies, HTTPSRedirectMiddleware and TrustedHostMiddleware together is the subject of Middleware, CORS & Proxies.

TLS in Uvicorn itself

Uvicorn can terminate TLS directly, which is reasonable for local HTTPS testing, an internal service, or a single small host without a proxy. It needs a certificate and a private key (for local work, mkcert or a self-signed certificate); the recommended source for a public certificate is Let’s Encrypt.

openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 1 -subj "/CN=localhost"
uvicorn main:app --port 8209 --ssl-keyfile key.pem --ssl-certfile cert.pem
curl -sk https://localhost:8209/healthz

The curl printed {"status":"ok"} (-k skips verification of the self-signed certificate; never do that outside of a local test), and a plain http:// request to the same port failed instead of being served. The drawbacks of doing it this way are the reasons the proxy design exists: no automatic renewal, one certificate, the port must be 443 (a privileged port on Linux), and a restart is needed to load a renewed certificate. For TLS needs the flags do not cover, such as mutual TLS, Uvicorn accepts an ssl_context_factory in uvicorn.run(); see its documentation. The key file is a secret: keep it out of Git and out of the image, and mount it from a secret store.

Pinning Versions

FastAPI is still a 0.x project, and under Semantic Versioning anything below 1.0.0 may introduce breaking changes. The project’s convention is that a PATCH release (the last number) contains bug fixes and non-breaking changes, while a MINOR release (the middle number) may contain new features and breaking changes. Therefore:

  • Pin FastAPI to the version you tested, either exactly or to a patch range.

  • Do not pin Starlette. Each FastAPI version requires a Starlette range it works with; let FastAPI choose.

  • Pydantic may be pinned to a range that works for you: Pydantic includes FastAPI’s tests in its own test suite, so new versions are compatible. The range is >=2.13.5,<3.0.0 here.

  • Add tests, then upgrade deliberately: raise the pin, run the tests, fix what broke, commit the new pin.

fastapi[standard]>=0.142.2,<0.143.0
pydantic>=2.13.5,<3.0.0

Resolving those two lines with pip install --dry-run produced fastapi 0.142.2, starlette 1.7.0, pydantic 2.13.5, uvicorn 0.54.0 and fastapi-cli 0.0.32, with no Starlette pin in the file. A range is convenient but still floats between installs; for reproducible deployments commit a lock file (the output of pip freeze or pip-compile, uv.lock, poetry.lock) so that the image you test is the image you ship; see the pip and uv references. A small startup check can make drift loud instead of subtle:

"""Fail fast if the environment drifted from what was tested."""
from importlib.metadata import version

from packaging.specifiers import SpecifierSet
from packaging.version import Version

EXPECTED = {
    "fastapi": SpecifierSet(">=0.142.2,<0.143.0"),
    "pydantic": SpecifierSet(">=2.13.5,<3.0.0"),
}


def check() -> dict[str, str]:
    installed = {name: version(name) for name in EXPECTED}
    for name, spec in EXPECTED.items():
        if Version(installed[name]) not in spec:
            raise RuntimeError(f"{name} {installed[name]} is outside {spec}")
    return installed


if __name__ == "__main__":
    print(check())

The script printed {'fastapi': '0.142.2', 'pydantic': '2.13.5'}. The packaging library it uses is not a FastAPI dependency, so add it to your requirements if you keep such a check. Running it from the pre-start step (Previous steps before starting) stops a bad rollout before a worker starts. When you upgrade, read the release notes between your pin and the target, since a MINOR release can change behaviour (for example 0.132.0 made strict content-type checking the default and 0.137.0 changed how routers are stored); the table of dated changes is in What’s Changed & Migration.

FastAPI Cloud

FastAPI Cloud is a hosting service built by the author and team of FastAPI (and the main sponsor of the FastAPI open-source projects), launched in the CLI with FastAPI 0.116.0 (2025-07-07). It aims to make deploying a FastAPI app one command and takes care of HTTPS, replication with autoscaling based on requests, and the other concepts of this page. The commands come with fastapi[standard]:

fastapi login        # authenticate; opens the browser
fastapi deploy       # detect the app, upload it, build and deploy it

If you are not logged in, fastapi deploy opens the browser for authentication by itself. The CLI detects the FastAPI application (so the [tool.fastapi] entrypoint from Declaring the entrypoint once helps), and when it finishes prints the URL, in the documentation example of the form https://myapp.fastapicloud.dev. Both commands exist in the installed CLI 0.0.32; fastapi deploy --help lists --no-wait, --app-id, --large-file-threshold and --json, fastapi login --help lists --no-open, --timeout and --json, and the fastapi cloud group also contains logs, env, domains, link, unlink, setup-ci, whoami, logout, teams and tokens. These commands were not run: they need a FastAPI Cloud account. For the quick start, environment variables, custom domains, autoscaling and CI integration, follow the FastAPI Cloud documentation linked below, which is the authority for that service.

Because it is a commercial platform, treat it like any other choice from Deployment Strategies at a Glance: it removes work, and the price is the platform’s pricing, limits and lock-in. FastAPI is open source and standards-based, so nothing in the code ties the app to it, and the container route stays open.

Cloud Providers

You can run FastAPI on virtually any cloud. The official guides point to your provider’s documentation and name some sponsors with their own guides (Render and Railway at the time of checking). For the three big clouds the site has its own guides, and the FastAPI-specific part is almost always the same: a container image with one Uvicorn process (fastapi run, port from the PORT environment variable, --forwarded-allow-ips for the platform’s proxy), a health endpoint, configuration from environment variables and secrets from the provider’s secret store.

Cloud

Container services

Pages

Azure

Container Apps, AKS, App Service

Container Apps, AKS clusters, App Service and Functions, choosing a compute service

AWS

ECS on Fargate, EKS, Lambda

ECS and Fargate, EKS clusters, Lambda, choosing a compute service

Google Cloud

Cloud Run, GKE

Cloud Run, GKE clusters, choosing a compute service

On Kubernetes (any cloud) the proxy role belongs to an ingress or the Gateway API, replication to the Deployment, and pre-start steps to an init container or a Job; see Ingress, Health Probes & Lifecycle and the container guide FastAPI in Docker & Kubernetes. A function-as-a-service setup, which runs the app per request instead of as a long-lived process, changes the model again: lifespan and in-process state behave differently there,, so read the platform’s guide: Lambda on AWS, or the Azure and Google Cloud pages in the table above.

Putting It Together: Which Setup for Which Situation

Situation Setup

Learning, demos, internal tools on one VM

fastapi run, --workers equal to the cores, systemd for start and restart, Caddy or Traefik for HTTPS

A containerised service on Kubernetes or a managed container platform

One Uvicorn process per container (fastapi run, no --workers), replication by the platform, a migration Job, probes on /healthz

Existing Gunicorn operations

gunicorn -k uvicorn_worker.UvicornWorker from the uvicorn-worker package, with a gunicorn.conf.py

The least operations work possible

FastAPI Cloud (fastapi login, fastapi deploy) or another platform that provides HTTPS and scaling

HTTP/2 or a different server runtime is required

Hypercorn or Granian under the same process model, or HTTP/2 at the proxy

Where Older Guides Are Outdated, and What Changed Recently

  • Gunicorn plus uvicorn.workers.UvicornWorker as the default. Uvicorn now manages workers itself (--workers, which restarts dead workers and supports SIGHUP), the uvicorn.workers module is deprecated in favour of the uvicorn-worker package (a DeprecationWarning on Uvicorn 0.54.0), and the old tiangolo/uvicorn-gunicorn-fastapi image is deprecated. Prefer --workers, or one process per container.

  • uvicorn main:app as the way to start. The fastapi command (fastapi dev, fastapi run) arrived in 0.111.0 (2024-05-03) and the fastapi[standard] extra in 0.112.0 (2024-08-02); the plain package no longer installs the server and the CLI, and the slim variant of the package was deprecated and then dropped in February 2026 (0.129.2, 0.128.8). Install fastapi[standard].

  • --reload outside development. Reload is for fastapi dev; fastapi run has none, and it cannot be combined with --workers.

  • --proxy-headers alone. Forwarded headers are enabled by default but only for trusted addresses (127.0.0.1 and ::1); the setting that matters is --forwarded-allow-ips (or FORWARDED_ALLOW_IPS).

  • Startup and shutdown events. @app.on_event is superseded by lifespan (0.93.0, 2023-03-07), and lifespan code runs once per worker, which matters for pools and caches.

  • The pin advice. Pin FastAPI, let it choose Starlette, and keep Pydantic to a compatible v2 range; Pydantic v1 is no longer supported (0.126.0, 2025-12-20).

  • Python versions. Python 3.8 was dropped in 0.125.0 (2025-12-17) and 3.9 in 0.129.0 (2026-02-12); 3.14 is supported since 0.118.3 (2025-10-10) and the official Dockerfile example now uses a recent Python base image.

  • FastAPI Cloud. fastapi deploy appeared in 0.116.0 (2025-07-07); older guides know only self-hosting and other platforms.

  • Tool-specific syntax ages quickly. Traefik configuration changed between major versions, and old tutorials use labels that current versions do not read. Copy from the current documentation for the version you run.

  • Native OpenTelemetry (0.142.0, 2026-09-29). Tracing no longer requires hand-written instrumentation; see the observability page of this section once it is published.

Pitfalls

  • Binding to 127.0.0.1 in a container. The default of uvicorn is 127.0.0.1, which is unreachable from outside the container. fastapi run listens on 0.0.0.0, which is why it is the better container command.

  • --workers inside Kubernetes. Several replicas times several workers multiplies memory and connections and hides the true process count from the scheduler. Scale replicas instead.

  • --reload or fastapi dev in production. Slower, less stable, and fastapi dev binds only to localhost, so it also looks broken.

  • Running pre-start migrations in every worker. Duplicate or conflicting migrations. Run them once.

  • Per-process state treated as global. An in-memory cache, counter or rate limiter differs per worker. Use a shared store.

  • Memory multiplied. A 1 GB model loaded at startup costs 4 GB with four workers. Measure resident memory per process and set the container memory limit accordingly.

  • Database pool times workers times replicas. Easily exceeds the database’s connection limit.

  • Trusting X-Forwarded- from everyone.* --forwarded-allow-ips "*" on a port the internet can reach lets any client forge its IP address and scheme.

  • Self-signed certificate with verification off. Fine locally, wrong in production; use a certificate authority.

  • Certificate storage not persistent. A proxy that loses acme.json requests new certificates on every restart and runs into rate limits.

  • Grace periods in the wrong order. If the platform kills the process before Uvicorn’s --timeout-graceful-shutdown elapses, in-flight requests are cut anyway.

  • Unpinned FastAPI. A routine rebuild pulls a new MINOR release with a breaking change. Pin and test.

  • Starting from an SSH session. The process dies with the connection and does not come back after a reboot. Use a supervisor.

  • Secrets in the image or the unit file. Pass them with an environment file or the platform’s secret store, not in the repository (see Settings, Lifespan & Background Tasks).