Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 064 / 100

Deploying FastAPI with Docker: best practices explained

A small, production-ready image.

Expona Admin7 min read

Introduction

You've built a solid FastAPI application, and now it's time to ship it. Containerizing and deploying your service with Docker is often where best practices fall apart—oversized images, missing health checks, and root user vulnerabilities sneak into production. This article walks through deploying FastAPI with Docker: best practices explained, covering image optimization, worker configuration, security, and observability patterns that we've refined across production workloads in e-commerce, SaaS, and real-time trading systems.

Dockerfile — Slim base, dependencies first for caching

The foundation of a production-ready deployment is a lean Dockerfile. Most teams start with python:3.12 and end up shipping gigabytes unnecessarily. We use python:3.12-slim as the base: it drops build tools and documentation, cutting image size by 70% without sacrificing runtime compatibility.

Layer ordering matters more than it seems. Docker caches layers based on content; if you copy your entire application first, then install dependencies, every code change invalidates the dependency cache. We reverse this: lock files first, then application code.

python
FROM python:3.12-slim

WORKDIR /app

# Copy dependency files first for cache efficiency
COPY pyproject.toml uv.lock ./

# Install uv, then sync locked dependencies without dev packages
RUN pip install uv && uv sync --frozen --no-dev

# Copy application code after dependencies
COPY . .

# Run uvicorn with multiple workers (see next section)
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

We've switched to uv for dependency resolution and installation. It's written in Rust, installs 20× faster than pip, and produces bit-for-bit reproducible lock files. The --frozen flag ensures you never accidentally upgrade a transitive dependency in production; --no-dev strips test runners and linters you don't need at runtime.

The resulting image typically weighs 300–400 MB, compared to 800 MB+ with full Python and development tools. In a fleet of 50 container instances, that's meaningful savings in registry storage, pull time, and cold-start latency.

Workers — Use multiple uvicorn workers or gunicorn with uvicorn workers

FastAPI's development server is single-threaded. In production, a single uvicorn worker handles one request at a time; everything else queues. We run multiple workers to saturate CPU and handle concurrent load.

The typical formula is 2 × CPU_cores + 1 workers. For a 2-core container, that's 5 workers. Each worker is a separate Python process, so you get true parallelism on multi-core hosts.

python
FROM python:3.12-slim

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

COPY . .

# Run 5 uvicorn workers; each gets a separate Python process
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "5"]

Alternatively, you can use Gunicorn as a process manager wrapping uvicorn workers. This is common in teams that already standardize on Gunicorn for other Python services:

python
FROM python:3.12-slim

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

COPY . .

# Gunicorn spawns 5 uvicorn workers and monitors them
CMD ["uv", "run", "gunicorn", "app.main:app", "--workers", "5", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]

Gunicorn adds process lifecycle management—automatic restarts if a worker dies, gradual shutdown during rolling deploys, and built-in metrics. We use Gunicorn in services handling thousands of requests per second; for smaller deployments, raw uvicorn with workers is simpler and sufficient.

Set --max-requests to force worker recycling after a certain number of requests (e.g., 1000). This mitigates memory leaks and keeps workers fresh without restart cascades.

Non-root user — Add a user and switch to it

Running as root inside a container is a security liability. If an attacker compromises your application, they own the container and potentially the host. A non-root user limits blast radius.

python
FROM python:3.12-slim

WORKDIR /app

# Create a non-root user early
RUN groupadd -r appuser && useradd -r -g appuser appuser

COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

COPY . .

# Change ownership of app directory
RUN chown -R appuser:appuser /app

# Switch to non-root user before running
USER appuser

CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "5"]

The -r flag creates a system user (UID < 1000), which is a convention for service accounts. chown ensures the app directory is readable by the new user. Most container platforms and Kubernetes policies enforce non-root anyway; building it into your Dockerfile saves friction during deploy.

If your application needs to write logs or temporary files, ensure those directories exist and are owned by the app user before the USER switch. Writing to unmapped volumes or read-only filesystems will silently fail and waste debugging time.

Health checks — Expose /health and use it for container checks

Orchestrators like Kubernetes and Docker Compose use health checks to detect and restart unhealthy containers. A missing health check means dead containers keep running and routing traffic to them, degrading user experience.

Add a lightweight health endpoint to your FastAPI app:

python
from fastapi import FastAPI
from datetime import datetime

app = FastAPI()

@app.get("/health")
async def health():
    return {
        "status": "ok",
        "timestamp": datetime.utcnow().isoformat()
    }

@app.get("/api/endpoint")
async def some_endpoint():
    # Your actual business logic
    pass

Expose the health check in your Dockerfile:

python
FROM python:3.12-slim

WORKDIR /app

RUN groupadd -r appuser && useradd -r -g appuser appuser

COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

COPY . .
RUN chown -R appuser:appuser /app

USER appuser

# Inform Docker/Kubernetes that the service listens on 8000
EXPOSE 8000

# Health check: curl /health every 10 seconds, timeout after 3 seconds
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:8000/health || exit 1

CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "5"]

The HEALTHCHECK instruction tells Docker to periodically probe the endpoint. --start-period=5s allows the app time to start before health checks are enforced. If the endpoint fails 3 times in a row, Docker marks the container unhealthy and the orchestrator replaces it.

For Kubernetes, use a separate liveness and readiness probe in your deployment manifest—those are more flexible than Docker's built-in health check—but the FastAPI endpoint pattern is identical.

Common mistakes

  • Using the full Python image: python:3.12 instead of python:3.12-slim bloats images by 2–3×. Slim is sufficient for 99% of FastAPI workloads; only switch to full if you need system libraries like graphviz or gcc.
  • Copying source before dependencies: Every code change invalidates the dependency layer, forcing a full pip install during Docker build. Layer ordering is free optimization.
  • Running a single worker: A single uvicorn worker on a multi-core container wastes CPU. Use the --workers flag or Gunicorn.
  • Forgetting to expose ports: EXPOSE is documentation for the Dockerfile consumer and required by some container registries. Include it even though it doesn't actually open ports.
  • No health checks: Dead containers silently accumulate traffic if health checks aren't configured. This is the most common production incident we see in the first week of deployment.
  • Running as root: Security scanning tools and compliance teams flag root containers; bake in a non-root user from the start.

Key takeaways

  • Use python:3.12-slim as the base image and order your Dockerfile layers to place dependency files first, then application code, for maximum cache efficiency.
  • Run multiple uvicorn workers (or Gunicorn + uvicorn workers) to handle concurrent load; 2 × CPU cores + 1 is a safe starting point.
  • Create a non-root user and switch to it before running the application to limit the impact of compromised containers.
  • Expose and wire a /health endpoint so that Docker and Kubernetes can detect and replace unhealthy instances automatically.
  • Test your Dockerfile locally with docker build and docker run, paying attention to image size and startup time—these are early signals of production problems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles