A production FastAPI Dockerfile is a multi-stage build on python:3.12-slim (or Alpine for the brave), installs dependencies in a builder stage, copies the installed packages and the application code into a slim runtime stage, runs as a non-root user, exposes a /health endpoint, and handles SIGTERM cleanly so the platform can drain in-flight requests before killing the container. The default FastAPI tutorial Dockerfile does almost none of that, which is why most production FastAPI deploys ship at 900MB and crash on every rolling restart.
This post is the version that ships. The first half is the Dockerfile. The second half is the parts the Dockerfile does not cover: the entrypoint, the health check, the graceful shutdown, the secrets, the static files, and the deploy-platform contract that ties the whole thing together.
The interesting thing about “FastAPI in Docker” is that the easy version works locally and breaks in production in three different ways. The breakages are all the same kind of breakage: the container does not behave the way the platform expects. The fix is the boring one — make the container behave the way the platform expects, and the platform’s scaling, restart, and zero-downtime deploy features actually work.
Table of contents
- The direct answer
- The Dockerfile that ships
- The entrypoint that does not drop requests
- The health check the platform actually pings
- The secrets pattern
- Static files and the SPA trap
- Multi-stage build, line by line
- The deploy platform contract
- The opinion this post is built on
- FAQ
The direct answer
The minimum production Dockerfile:
# syntax=docker/dockerfile:1.6
# ----- builder -----
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libyaml-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ----- runtime -----
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libyaml-0-2 \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r app && useradd -r -g app -d /app -s /usr/sbin/nologin app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
&& rm -rf /wheels
COPY --chown=app:app . .
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "60", \
"--graceful-timeout", "30", \
"--access-logfile", "-", \
"--error-logfile", "-"]
That is the working version. The rest of the post is what each line is doing and why the easy version does not.
The Dockerfile that ships
The shape of the Dockerfile matters more than the specific Python version, the exact base image, or the gunicorn flags. The shape is:
- A builder stage that installs the build tools, compiles dependencies, and produces wheels. The build tools are not in the final image.
- A runtime stage that has only what the application needs to run. No compilers, no headers, no pip cache.
- A non-root user for the runtime. The container should not be able to do anything to the host that requires root.
- A health check that pings a real endpoint, not a TCP port. The platform uses the health check to decide when traffic can route to the container.
- A graceful-shutdown-aware entrypoint that handles SIGTERM and finishes in-flight requests before exiting.
The first three are about image size and security. The last two are about deploy behavior. Both matter.
The image size difference is dramatic. A naive Dockerfile that uses python:3.12 and pip install -r requirements.txt is around 900MB. A multi-stage build on python:3.12-slim is around 200MB. The smaller image deploys faster, starts faster, and is harder to attack because it has fewer tools in it.
The entrypoint that does not drop requests
The trap is the default CMD ["uvicorn", "app.main:app"]. Uvicorn handles SIGTERM by exiting immediately. Any in-flight request is cut off. The platform sees the container die, but the user sees a 502.
The fix is gunicorn with the uvicorn worker class:
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "60", \
"--graceful-timeout", "30", ...]
gunicorn handles SIGTERM by stopping new connections, waiting for in-flight workers to finish, and only then exiting. The --graceful-timeout=30 is the maximum time gunicorn will wait for in-flight requests to finish before forcing an exit.
The platform’s rolling deploy contract is: send SIGTERM, wait for the container to exit, replace it with the new version. If the container exits in 50ms, in-flight requests die. If the container exits in 5-30 seconds after finishing its in-flight work, the rolling deploy is zero-downtime. The gunicorn flags are the difference.
The same pattern applies to background workers (Celery, ARQ) — they need their own SIGTERM handling. The default worker is “die on signal,” and the production worker is “finish the current job, then die.” The platform’s restart contract only works for the second.
The health check the platform actually pings
The trap is the EXPOSE 8000 plus a TCP port check. The platform’s health check pings the port, gets a connection, declares the container healthy, and routes traffic to it — even if the application inside is broken.
The fix is an HTTP health endpoint that does real work:
@app.get("/health")
async def health():
# Check database
db.execute(text("SELECT 1"))
# Check Redis
redis.ping()
return {"status": "ok"}
The endpoint returns 200 if the application can talk to its dependencies. Returns 503 if it cannot. The platform’s health check pings the endpoint, and the container is only declared healthy when the application is actually ready to serve traffic.
The Docker HEALTHCHECK directive in the Dockerfile is the backup. It is what the Docker daemon itself uses to decide the container’s state, separate from the platform’s check:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
The platform should use the same endpoint. If the platform and Docker disagree on health, the deploy behavior is undefined.
The secrets pattern
The trap is the ENV DATABASE_URL=... line in the Dockerfile. The build log now contains the password. The image now contains the password. Anyone who can pull the image can read the password.
The fix is environment variables at runtime, not build time. The platform injects the secrets when it starts the container. The Dockerfile has no ENV line for credentials. The application reads os.environ["DATABASE_URL"] at startup.
For a deeper look at the secrets and connection-pool side of the same problem, the Flask + MySQL guide covers the connection string pattern that goes into the runtime environment.
A platform that exposes a secret store as environment variables at container start is the platform that turns “the password is in the build log” from a one-time mistake into a structurally impossible class of bug. The Dockerfile has no ENV line, and the application reads os.environ. The platform bridges the two. The RunxBuild platform is built around that pattern.
Static files and the SPA trap
The trap is serving static files from FastAPI. FastAPI is good at JSON. It is not good at serving /static/* at scale, and the static-asset performance is one of the things that decides whether the page feels fast.
For a pure API, this section does not apply. For a FastAPI app that serves a frontend (templates or a static SPA), the right pattern is:
- FastAPI serves the JSON API at
/api/*. - A reverse proxy (Caddy, Nginx, the platform’s CDN) serves the static files at
/static/*and falls through to FastAPI for everything else. - The static files are versioned in their URL (
/static/main.abcd1234.js) so the browser caches them aggressively and the deploy can invalidate them by changing the filename.
If the platform has a built-in static-site host, that is the better answer for the frontend. The FastAPI container serves only the API. The static host serves the SPA. They share a custom domain. The platform handles the routing.
Multi-stage build, line by line
The builder stage:
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libyaml-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
build-essential is the C compiler toolchain, needed for any Python package with a C extension (numpy, pandas, psycopg, etc.). libyaml-dev is the YAML parser library, needed for PyYAML’s C extension. The pip wheel command produces wheel files in /wheels — pre-compiled, ready to install without recompilation.
The --no-cache-dir flag prevents pip from caching the wheel download. The cache is not needed in the builder (the stage is throwaway) and it bloats the image.
The runtime stage:
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libyaml-0-2 \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r app && useradd -r -g app -d /app -s /usr/sbin/nologin app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
&& rm -rf /wheels
The runtime stage has the runtime libraries (libyaml-0-2, not libyaml-dev) but not the build tools. The wheels are installed with --no-index (only the local cache) and then deleted. The container is left with the application dependencies and no trace of how they were built.
The groupadd and useradd create a non-root user. The COPY --chown=app:app . . makes the application files owned by the same user. The USER app switches the runtime to the non-root user. The container can no longer do anything that requires root.
The deploy platform contract
The Dockerfile is half the story. The platform is the other half. A platform that:
- Reads the Dockerfile and builds the image
- Injects the secrets at container start
- Pings the health check before routing traffic
- Sends SIGTERM and waits for the container to exit before replacing it
- Streams the container logs to a central place
- Mounts persistent volumes for data the application should not lose
…is a platform where the Dockerfile just works. A platform that does not do those things is a platform where the Dockerfile is the start of a long debugging session.
For a sanity check on the deploy cost, the hosting cost calculator gives a real number to compare against. The cheapest deploy is rarely the one that handles SIGTERM and secrets correctly.
The opinion this post is built on
The reason most “FastAPI in Docker” tutorials ship a 900MB container that runs as root is that the tutorials are about the framework, not the deploy. FastAPI the framework is excellent. FastAPI the deploy is a Dockerfile, a platform, and a contract, and most tutorials stop at the framework.
The boring version of the deploy — multi-stage build, non-root user, real health check, gunicorn with graceful timeout, secrets from the environment — is the version that actually ships. None of those lines is exciting. All of them are the difference between “the deploy works” and “the deploy is a story the team tells for a quarter.”
The platform is the multiplier. A platform that bakes in graceful shutdown, health-checked deploys, secret injection, and log streaming turns the Dockerfile into a one-page artifact. A platform that does not forces the team to implement all of it by hand, and the team will get it wrong in a different way for every service. The Dockerfile is the contract. The platform is the enforcement.
Ship the boring Dockerfile. Use the platform that respects the contract. Do not debug the rolling-restart 502 again.
FAQ
What is the smallest production FastAPI Docker image?
Around 80-120MB with python:3.12-alpine as the base, around 180-220MB with python:3.12-slim. The Alpine image is smaller but has a different libc and occasionally surprises with C-extension builds. The slim image is the boring default; the Alpine image is for teams that know what they are doing and have a reason.
Should I use uvicorn or gunicorn in production?
Use gunicorn with the uvicorn worker class (uvicorn.workers.UvicornWorker). Gunicorn handles SIGTERM cleanly, manages worker processes, and is the right answer for production. Uvicorn alone is fine for development, but its signal handling is “exit immediately,” which drops in-flight requests on a rolling deploy.
How do I make the container not run as root?
RUN groupadd -r app && useradd -r -g app -d /app -s /usr/sbin/nologin app
...
USER app
The useradd -s /usr/sbin/nologin prevents the user from being used to log into the host if the container is compromised. The USER app switches the container’s runtime user. The application runs as a user with no host privileges.
Why is my container crashing on every deploy?
Almost always because the entrypoint does not handle SIGTERM. The platform sends SIGTERM to drain in-flight traffic, the container exits immediately, in-flight requests die with a 502. The fix is gunicorn with --graceful-timeout=30, or a custom signal handler that drains before exit.
How many workers should I configure?
A reasonable starting point is (2 * CPU cores) + 1. For a 2-core container, that is 5 workers. For a 4-core container, that is 9 workers. The exact number depends on whether the application is CPU-bound or I/O-bound; the formula is a starting point, not a law. Profile under load and adjust.
How do I keep secrets out of the image?
Read secrets from environment variables at runtime, not from the Dockerfile. The Dockerfile has no ENV line for credentials. The platform injects the secrets when it starts the container. The build log has no password. The image has no password. The application reads os.environ["DATABASE_URL"] at startup.