Guides · Deployment
FastAPI Dockerfile: run FastAPI in production
A short, tested Dockerfile for FastAPI, what each line does, and the proxy setting that makes request URLs say https.
By Jonathan Pitter ·
The short answer
# syntax=docker/dockerfile:1
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /code
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY ./app ./app
RUN useradd --system --create-home app
USER app
ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec fastapi run app/main.py --port $PORT"]
With requirements.txt containing fastapi[standard] and the app in app/main.py:
from fastapi import FastAPI, Request
app = FastAPI()
@app.get("/")
def root(request: Request):
return {"scheme": request.url.scheme, "host": request.headers["host"]}
@app.get("/healthz")
def healthz():
return {"ok": True}
We built it on Python 3.14 and ran it as the non-root user. One environment variable changes how it behaves behind a proxy; see "Behind a proxy or load balancer" below.
What each line does
fastapi[standard] installs FastAPI with the fastapi command and uvicorn. Plain fastapi has neither, and the CMD would fail with "fastapi: not found".
COPY requirements.txt . before the code keeps the installed packages in their own cached layer, so changing main.py doesn't reinstall them.
fastapi run app/main.py is the production mode of the FastAPI CLI. It runs uvicorn listening on 0.0.0.0, without auto-reload. (fastapi dev is the development mode: reload on, 127.0.0.1 only.)
sh -c "exec ... --port $PORT" reads the port from the environment when the container starts, which is how most platforms assign it. exec makes the server PID 1, so it receives SIGTERM and shuts down in under a second instead of being killed after the grace period.
useradd --system --create-home app runs the app without root.
Behind a proxy or load balancer
On almost every host, TLS ends at a proxy in front of your container, which forwards plain HTTP and sets X-Forwarded-Proto: https. uvicorn only trusts those headers from 127.0.0.1 by default, so your app sees http:
| Setting | request.url.scheme for an HTTPS request |
|---|---|
| default | http |
FORWARDED_ALLOW_IPS=* | https |
We measured both with the image above. It matters for anything that builds a URL: redirects from redirect_slashes, url_for, OAuth callback URLs. Set FORWARDED_ALLOW_IPS=* in the environment when only the proxy can reach the container (true on container platforms, where the app isn't exposed directly), or set it to the proxy's address.
Workers
fastapi run starts one process. For CPU-bound work or a busy service, add workers:
CMD ["sh", "-c", "exec fastapi run app/main.py --port $PORT --workers 4"]
On a container platform it's usually simpler to keep one worker per container and run more replicas, so the platform does the load balancing and restarts.
Add a .dockerignore
.git
.venv
__pycache__
.env*
Dockerfile
.dockerignore
Build and run it
docker build -t my-api .
docker run -p 8000:8000 -e PORT=8000 my-api
curl localhost:8000/healthz
Connecting to PostgreSQL
Read the URL from the environment once, at startup. With SQLAlchemy:
import os
from sqlalchemy import create_engine
url = os.environ["DATABASE_URL"].replace("postgres://", "postgresql+psycopg://", 1)
engine = create_engine(url, pool_pre_ping=True)
SQLAlchemy doesn't accept the postgres:// scheme many hosts use, hence the replace. Add psycopg[binary] and sqlalchemy to your requirements. On Staxa, a tenant with a database gets DATABASE_URL (plus DB_HOST, DB_PORT, DB_NAME, DB_USER and DB_PASSWORD), with sslmode=disable because the database is inside the tenant's private network.
FastAPI on Staxa
If the repository has no Dockerfile, Staxa detects FastAPI from fastapi in requirements.txt, a Pipfile or pyproject.toml, and generates one. It looks for the module that creates the app in main.py, then app/main.py, app.py, src/main.py and api/main.py, and uses the first one containing FastAPI(. For the layout above, on port 8000 with Python 3.14 selected:
FROM python:3.14-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt* Pipfile* pyproject.toml* ./
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; \
elif [ -f Pipfile ]; then pip install pipenv && pipenv install --system --deploy; \
elif [ -f pyproject.toml ]; then pip install --no-cache-dir .; fi
RUN pip show uvicorn >/dev/null 2>&1 || pip install --no-cache-dir uvicorn
COPY . .
RUN useradd --system --create-home app
USER app
ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port $PORT"]
The app object must be named app. uvicorn is installed if your requirements don't include it. PORT is set to the service's port, https://<service>-<tenant>.staxa.app routes to it with TLS, and readiness is checked with GET / unless you set a health check path such as /healthz. Add FORWARDED_ALLOW_IPS=* to the service's environment for https URLs; uvicorn reads it whichever way it's started. The FastAPI and PostgreSQL demo is a complete app with its own Dockerfile, which Staxa builds instead of generating one.
Common problems
fastapi: not found
requirements.txt has fastapi without the [standard] extra. Use fastapi[standard], or start uvicorn yourself: uvicorn app.main:app --host 0.0.0.0 --port $PORT.
Error loading ASGI app. Could not import module "main"
The module path doesn't match the files. With WORKDIR /code and the app in /code/app/main.py, the import path is app.main:app, and app/ needs to be importable from the working directory.
Redirects point at http://
The proxy headers aren't trusted. Set FORWARDED_ALLOW_IPS.
The container takes 10 seconds to stop
The CMD runs a shell that stays as PID 1. Use exec in sh -c, as above.
Frequently asked questions
Do I still need gunicorn with uvicorn workers?
No. uvicorn has its own --workers option and restarts dead workers, which covers what the gunicorn and UvicornWorker combination used to be for.
Should I use the tiangolo/uvicorn-gunicorn-fastapi image?
No. Its author deprecated it; the FastAPI documentation now recommends building from the official Python image, as above.
Which Python version?
Any version FastAPI supports. The examples use 3.14; check that your dependencies publish wheels for a release before moving to it, or the install falls back to compiling them.