Type something to search...
Deploying a Python Web App with Docker

Deploying a Python Web App with Docker

"It works on my machine" is the oldest problem in deployment, and Python has its own flavor of it: a different Python version on the server, a missing system library for a database driver, a dependency that resolved to a newer release than the one you tested. Docker solves this by packaging your app, its exact dependencies, and the Python runtime into one image that runs the same way on your laptop, in CI, and in production.

Writing a Dockerfile that merely works is easy. Writing one that builds fast, produces a small image, runs as a non-root user, shuts down cleanly, and reports its health takes a bit more care. This guide builds that up step by step for a small FastAPI app backed by PostgreSQL, then covers a uv-based variant, Docker Compose for local development, and what changes for Django and Flask.

The Example App

The app is a minimal FastAPI service with a health check that verifies the database connection:

# app/main.py
import os

from fastapi import FastAPI, Response, status
from sqlalchemy import create_engine, text

DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./local.db")

engine = create_engine(DATABASE_URL, pool_pre_ping=True)
app = FastAPI(title="Notes API")


@app.get("/")
def index() -> dict[str, str]:
    return {"message": "Hello from a container"}


@app.get("/health")
def health(response: Response) -> dict[str, str]:
    try:
        with engine.connect() as conn:
            conn.execute(text("SELECT 1"))
    except Exception:
        response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
        return {"status": "unhealthy"}
    return {"status": "ok"}

Two habits here make containerizing easier:

  • Configuration comes from environment variables. The same image then runs in every environment; only the variables change. Never bake secrets or environment-specific settings into the image. (For a typed approach to this, see the pydantic-settings section of data validation with Pydantic.)
  • A health endpoint lets Docker, load balancers and orchestrators know whether the app can actually serve requests, not just whether the process is alive.

Dependencies are pinned in requirements.txt:

# requirements.txt
fastapi==0.142.2
uvicorn[standard]==0.54.0
sqlalchemy==2.1.3
psycopg[binary]==3.3.6

Pin exact versions (or use a lock file, covered later) so the image you build next month contains the same code you tested today.

The project layout:

notes-api/
├── app/
│   ├── __init__.py
│   └── main.py
├── .dockerignore
├── compose.yaml
├── Dockerfile
└── requirements.txt

A First Dockerfile

Here's the simplest Dockerfile that works:

FROM python:3.13
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

It runs, but it has several problems: the full python:3.13 image is around a gigabyte, every code change reinstalls all dependencies, it runs as root, and it copies whatever happens to be in your folder (including .env files and .git) into the image. Let's fix each of these.

A Production-Ready Dockerfile

# syntax=docker/dockerfile:1

# ---- Build stage: install dependencies into a virtual environment ----
FROM python:3.13-slim AS builder

ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt


# ---- Runtime stage: copy only what's needed to run ----
FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

RUN useradd --create-home --uid 10001 appuser

WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY --chown=appuser:appuser app ./app

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD ["python", "-c", "import urllib.request, sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"]

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--proxy-headers"]

Let's go through the decisions. (The # syntax=docker/dockerfile:1 line at the top is a parser directive that opts into the current Dockerfile syntax; it has to be the very first line of the file.)

The Base Image: python:3.13-slim

The official slim variants are Debian with just enough installed to run Python, a fraction of the size of the full image. They use glibc, so prebuilt wheels from PyPI (for NumPy, psycopg, cryptography and so on) install without compiling.

You'll see Alpine-based images recommended for size. For Python they're usually a poor trade: Alpine uses musl instead of glibc, many packages lack musl wheels or need compiling from source, and the build ends up slower and the image often no smaller.

Pin the Python minor version (3.13), not latest, so a new Python release doesn't change your runtime unexpectedly. For fully reproducible builds you can pin the image digest too.

Multi-Stage Builds

The file has two FROM lines. The first stage (builder) creates a virtual environment and installs dependencies into it. The second stage starts fresh from a clean slim image and copies in only the finished virtual environment and your code.

Anything needed only to build (compilers, build headers, pip's caches) stays behind in the builder stage. That matters most when a dependency has to be compiled: you could apt-get install build-essential in the builder, and none of it would end up in the final image.

Installing into a virtual environment at /opt/venv makes the copy between stages a single, self-contained directory. Putting its bin folder first in PATH means python and uvicorn resolve to the venv versions without activation. (Background in virtual environments with venv.)

Layer Caching: Dependencies Before Code

Docker caches each instruction as a layer and reuses it if nothing that went into it changed. The order of instructions decides how often the cache is invalidated:

COPY requirements.txt .
RUN pip install -r requirements.txt   # cached until requirements.txt changes
...
COPY --chown=appuser:appuser app ./app   # changes on every code edit

Because requirements.txt is copied and installed before the application code, editing main.py only invalidates the final COPY. Rebuilds take seconds instead of reinstalling every package. Copying the whole project before pip install (as in the first Dockerfile) throws that away.

Environment Variables

  • PYTHONDONTWRITEBYTECODE=1 stops Python writing .pyc files at runtime, which would be lost when the container is replaced anyway.
  • PYTHONUNBUFFERED=1 sends print() and log output straight to stdout instead of buffering it. Without it, logs can appear late or be lost when a container crashes. Container platforms collect stdout and stderr, so log there rather than to files.
  • PIP_NO_CACHE_DIR=1 keeps pip's download cache out of the layer.

Running as a Non-Root User

By default, processes in a container run as root. If an attacker finds a vulnerability in your app, root inside the container makes it much easier to do damage, and it's a step closer to the host if a container escape bug exists.

useradd creates an unprivileged user, COPY --chown makes the app files belong to it, and USER appuser switches to it for everything after, including the CMD. The fixed UID (10001) makes file permissions on mounted volumes predictable. Most platforms (and many Kubernetes security policies) expect non-root images.

The Server Process

uvicorn is an ASGI server. The flags:

  • --host 0.0.0.0 listens on all interfaces. The default, 127.0.0.1, is unreachable from outside the container, which is the most common "my container runs but I can't connect" bug.
  • --workers 2 runs two worker processes so the app can use more than one CPU core and survives a single worker crashing. A typical starting point is one or two per CPU core available to the container; measure from there.
  • --proxy-headers trusts X-Forwarded-For and X-Forwarded-Proto from a reverse proxy, so the app sees the real client IP and scheme. By default Uvicorn only trusts these from 127.0.0.1; set --forwarded-allow-ips to your proxy's address if it runs elsewhere.

The Exec Form of CMD and Clean Shutdowns

CMD ["uvicorn", ...] (a JSON array) is the exec form: Docker runs Uvicorn directly as process 1. The shell form, CMD uvicorn app.main:app ..., wraps it in /bin/sh -c, and the shell doesn't forward signals. When Docker stops the container it sends SIGTERM, which Uvicorn handles by finishing in-flight requests and exiting. With the shell form, the signal never reaches Uvicorn, Docker waits 10 seconds, then kills it hard, cutting off active requests.

Health Checks

HEALTHCHECK tells Docker how to test the app. The slim image doesn't include curl, but it does include Python, so a one-liner with urllib.request does the job without installing anything. A non-zero exit marks the container unhealthy; docker ps shows the status, and Compose can wait on it (shown below). Orchestrators like Kubernetes ignore the Dockerfile HEALTHCHECK and use their own probes, but they'd call the same /health endpoint.

Keep Junk Out with .dockerignore

When you build, Docker sends the build context (your project folder) to the builder. A .dockerignore file excludes things that should never end up in an image:

# .dockerignore
.git
.venv
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
*.db
Dockerfile
compose.yaml

This matters for security as much as speed. Without it, COPY . . would copy your local .env with real credentials into an image layer, and anyone who can pull the image can extract it, even if a later instruction deletes the file.

Building and Running

docker build -t notes-api:1.0.0 .

docker run --rm -p 8000:8000 \
  -e DATABASE_URL="sqlite:////tmp/notes.db" \
  notes-api:1.0.0

-p 8000:8000 maps port 8000 on your machine to port 8000 in the container. Visit http://localhost:8000/health and you should get {"status":"ok"}. (The app runs as appuser, which can't write to /app, hence the SQLite file in /tmp for this quick test.)

Check the size and health:

docker image ls notes-api
docker ps   # STATUS shows "(healthy)" once the check passes

Tag images with a version or Git commit SHA rather than only latest, so you always know exactly what's running and can roll back.

Using uv Instead of pip

If you manage the project with uv, with dependencies in pyproject.toml and a uv.lock lock file, the Dockerfile changes slightly and installs get much faster:

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:0.12.22 /uv /bin/uv

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0 \
    UV_PROJECT_ENVIRONMENT=/opt/venv

WORKDIR /app

# Install dependencies only (cached until the lock file changes)
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-install-project --no-dev


FROM python:3.13-slim

ENV PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY --chown=appuser:appuser app ./app
USER appuser

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--proxy-headers"]

What's different:

  • uv is copied in from its official image instead of being installed with pip. Pin its version too.
  • --locked fails the build if uv.lock is out of date with pyproject.toml, so the image always matches the lock file you committed.
  • --no-install-project installs only the dependencies, not your own package, which keeps this layer cached across code changes. --no-dev skips test and lint tools.
  • The cache mount (--mount=type=cache) keeps uv's download cache on the build machine between builds without putting it in the image. UV_LINK_MODE=copy is needed because the cache lives on a different filesystem than the venv.
  • UV_COMPILE_BYTECODE=1 precompiles .pyc files at build time, which speeds up container startup. Because the bytecode is created at build time, PYTHONDONTWRITEBYTECODE is no longer needed.
  • UV_PYTHON_DOWNLOADS=0 makes uv use the image's Python instead of downloading its own.

Docker Compose for Local Development

Real apps need more than one container. Compose describes the whole stack in one file:

# compose.yaml
services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+psycopg://notes:notes@db:5432/notes
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: notes
      POSTGRES_PASSWORD: notes
      POSTGRES_DB: notes
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:
docker compose up --build

Points to notice:

  • Services reach each other by service name. Inside the web container, the database host is db, not localhost (localhost would be the web container itself).
  • depends_on with condition: service_healthy waits for Postgres to pass its health check before starting the app. Plain depends_on only waits for the container to start, which is before the database accepts connections.
  • The named volume pgdata keeps database data across docker compose down and up. docker compose down -v deletes it.
  • The plain-text password is fine for local development only. In production, use your platform's secret management, and don't publish the database port.

For a faster edit loop, the develop.watch section of Compose (used with docker compose watch) can sync source changes into the running container, or you can bind-mount your code and run Uvicorn with --reload in a development-only override file.

Database Migrations

Don't run migrations automatically every time a container starts. With several replicas, they'd race each other. Run them as a separate one-off step in your deploy pipeline, using the same image:

# Alembic (SQLAlchemy projects)
docker compose run --rm web alembic upgrade head

# Django
docker compose run --rm web python manage.py migrate

Running migrations from the same image guarantees they match the code being deployed.

Django and Flask

The Dockerfile structure stays the same; only the server command and a few framework details change.

Flask (a WSGI app) is served with Gunicorn:

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-", "app:app"]

app:app means "the app object in app.py". If you use an application factory, Gunicorn accepts "app:create_app()". --access-logfile - sends access logs to stdout.

Django also runs under Gunicorn (config.wsgi:application), or under Uvicorn via config.asgi:application if you use async views. It needs two extra steps:

# Collect static files into STATIC_ROOT at build time
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "config.wsgi:application"]

collectstatic runs during the build with a throwaway secret, because settings modules often require one just to import. Serve the collected files with WhiteNoise, a CDN, or a reverse proxy, since Gunicorn isn't designed to serve static files efficiently. Set DEBUG=False, ALLOWED_HOSTS, and the real secret key through environment variables at runtime, and run python manage.py check --deploy in CI. Getting started with Django covers those settings.

Production Checklist

  • Small, pinned base image: python:3.13-slim, with a pinned Python minor version.
  • Pinned dependencies: exact versions in requirements.txt or a lock file.
  • Multi-stage build so build tools don't ship.
  • Dependencies installed before code is copied, for fast rebuilds.
  • .dockerignore excluding .git, .env, virtual environments and caches.
  • Non-root user.
  • Exec-form CMD so SIGTERM reaches the server and shutdowns are graceful.
  • A real server (Uvicorn or Gunicorn) with an appropriate number of workers, never a framework's development server.
  • Configuration and secrets from the environment or a secret manager, never in the image.
  • Logs to stdout, unbuffered.
  • A health endpoint and a HEALTHCHECK or platform probe that uses it.
  • Migrations as a separate deploy step.
  • Scan images for known vulnerabilities (docker scout, Trivy, or your registry's scanner) and rebuild regularly to pick up base-image security fixes.
  • Versioned tags (Git SHA or release number), not just latest.

Conclusion

A good Python Dockerfile follows a handful of rules: start from a slim official image, install pinned dependencies into a virtual environment in a build stage, copy only that environment and your code into the final stage, and order instructions so code changes don't reinstall everything. Then run as a non-root user with an exec-form CMD, a real application server bound to 0.0.0.0, and a health check. Add a .dockerignore, keep configuration in environment variables, and use Compose to run the app alongside its database locally. The result is an image that behaves the same everywhere you run it.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading