Docker for Python Developers – Practical Guide (2026)

Build small, secure images for Flask or FastAPI apps and run them with a database using Docker Compose.

What you will build

  • A multi-stage Dockerfile on python:3.13-slim
  • Fast, reproducible installs with uv (and a plain pip version)
  • A .dockerignore that keeps secrets and junk out of the image
  • A non-root user and a container HEALTHCHECK
  • A compose.yaml running a FastAPI app with PostgreSQL

1. Project layout


myapi/
├── app/
│   ├── __init__.py
│   └── main.py
├── pyproject.toml
├── uv.lock
├── Dockerfile
├── .dockerignore
└── compose.yaml
            

Commit uv.lock. It pins every dependency, so the image you build today and the one you build next month install exactly the same versions.

2. A tiny app with a health endpoint


# app/main.py
import os

import psycopg
from fastapi import FastAPI

app = FastAPI()
DATABASE_URL = os.environ["DATABASE_URL"]


@app.get("/health")
def health() -> dict:
    return {"status": "ok"}


@app.get("/db")
def db_time() -> dict:
    with psycopg.connect(DATABASE_URL) as conn:
        now = conn.execute("SELECT now()").fetchone()[0]
    return {"db_time": now.isoformat()}
            

# pyproject.toml
[project]
name = "myapi"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
    "fastapi>=0.115",
    "uvicorn[standard]>=0.32",
    "psycopg[binary]>=3.2",
]
            

Run uv lock once on your machine to create uv.lock. Using Flask instead? Swap FastAPI/uvicorn for flask and gunicorn; everything else below stays the same.

3. Multi-stage Dockerfile with uv


# syntax=docker/dockerfile:1
# ---------- build stage ----------
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=never
WORKDIR /app

# 1) dependencies only: this layer is cached until uv.lock changes
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-install-project

# 2) then your code
COPY app ./app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

# ---------- runtime stage ----------
FROM python:3.13-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/app/.venv/bin:$PATH"

RUN groupadd --system app && useradd --system --gid app --home /app app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app

USER app
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"]
            
  • Two stages: uv and build caches stay in builder; the final image only gets the ready .venv and your code.
  • Layer order: dependencies are copied before the code, so editing main.py does not reinstall packages.
  • Healthcheck without curl: slim images have no curl, so the check uses Python's own urllib.
  • Non-root: the app user owns /app; a bug in your code cannot write to system files.

4. Same thing with plain pip


FROM python:3.13-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH"
RUN useradd --system --create-home app
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY --chown=app:app app ./app
USER app
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8000", "app.main:app"]
            

Copying the whole virtual environment between stages works because both stages use the same base image and Python path.

5. .dockerignore


.git
.venv
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.env
*.sqlite3
node_modules/
Dockerfile
compose.yaml
            

Without this file, COPY . . sends your local virtualenv, Git history and .env secrets into the build. It also makes builds slower because the context is larger.

6. Compose: app + PostgreSQL


# compose.yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

volumes:
  pgdata:
            

Put POSTGRES_PASSWORD=change-me in a local .env file (ignored by Git and Docker). The :? syntax makes Compose stop with an error if the variable is missing, and condition: service_healthy waits until Postgres accepts connections.

7. Everyday commands


docker compose up --build -d      # build and start
docker compose ps                 # shows (healthy) / (unhealthy)
docker compose logs -f api        # follow app logs
curl http://localhost:8000/db     # test the database route
docker compose exec api whoami    # prints: app (not root)
docker compose down               # stop (add -v to delete DB data)
            

8. Common mistakes

  • Using localhost for the database: inside a container it means the container itself. Use the service name, db.
  • Huge images: use -slim, multi-stage builds and --no-cache-dir. Alpine often makes Python builds slower because many wheels are not available for musl.
  • Secrets in the image: never COPY .env or use ENV SECRET_KEY=... in a Dockerfile. Pass them at runtime.
  • Binding to 127.0.0.1: the server must listen on 0.0.0.0 to be reachable from outside the container.

Checklist

  • Pinned base image (python:3.13-slim) and a lock file
  • Multi-stage build and a .dockerignore
  • Non-root USER and a HEALTHCHECK
  • Secrets passed as environment variables, never baked in

Related: Dockerfile generator · GitHub Actions for Python · FastAPI vs Flask 2026