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
Dockerfileonpython:3.13-slim - Fast, reproducible installs with
uv(and a plainpipversion) - A
.dockerignorethat keeps secrets and junk out of the image - A non-root user and a container
HEALTHCHECK - A
compose.yamlrunning 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.venvand your code. - Layer order: dependencies are copied before the code, so editing
main.pydoes not reinstall packages. - Healthcheck without curl: slim images have no curl, so the check uses Python's own
urllib. - Non-root: the
appuser 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
localhostfor 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 .envor useENV SECRET_KEY=...in a Dockerfile. Pass them at runtime. - Binding to 127.0.0.1: the server must listen on
0.0.0.0to 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
USERand aHEALTHCHECK - Secrets passed as environment variables, never baked in
Related: Dockerfile generator · GitHub Actions for Python · FastAPI vs Flask 2026