TechEarl

How to Dockerize a Python App (Django / Flask / FastAPI)

A Dockerfile for a real Python app: slim vs alpine, pip install with --no-cache-dir, multi-stage when you need build tools, PYTHONUNBUFFERED, and the gunicorn / uvicorn entrypoint.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

A solid Python Dockerfile is shorter than the Node equivalent, but it has a couple of Python-specific gotchas: pip caches you don't want in the image, native-wheel mismatches with Alpine, and PYTHONUNBUFFERED — without it, print() output doesn't reach docker logs until the buffer flushes (which is approximately never on a short-lived script).

A working Dockerfile

dockerfile
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
USER 1000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "myapp.wsgi:application"]

Build and run:

bash
docker build -t my-py-app .
docker run -d --name my-py-app -p 8000:8000 my-py-app

For an async stack (FastAPI / Starlette), swap the CMD:

dockerfile
CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]

Pick the right Python image

  • python:3.13-slim — Debian-slim, glibc, all native wheels work. The default I reach for.
  • python:3.13 — full Debian, much bigger but includes build tools. Use in a build stage if you need to compile native dependencies; almost never as a runtime image.
  • python:3.13-alpine — Alpine + musl. Smaller (~50 MB vs ~150 MB) but trips up many scientific Python packages (NumPy, SciPy, Pandas, cryptography) that ship glibc wheels only. Use only if you've verified your dependencies install cleanly.

Pin to a specific minor version (3.13), not latest. The Python release cadence is yearly; minor versions change behavior.

The four env vars worth setting

dockerfile
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PIP_NO_CACHE_DIR=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
  • PYTHONUNBUFFERED=1 — Python's stdout/stderr are line-buffered when attached to a TTY and block-buffered when attached to a pipe (which docker logs is). Without this, your print() and logging output can sit in the buffer for minutes. Set it.
  • PYTHONDONTWRITEBYTECODE=1 — skip writing .pyc files. They aren't useful inside a container that gets rebuilt anyway and they add noise.
  • PIP_NO_CACHE_DIR=1 — pip caches downloaded wheels. In an image, that cache is wasted space. Disable it.
  • PIP_DISABLE_PIP_VERSION_CHECK=1 — stops the "you have pip X, latest is Y" warning during every install. Cosmetic but cleaner logs.

Dependency-cache layer pattern

Same idea as Node: copy requirements.txt (or pyproject.toml / poetry.lock) first, install, then copy the source. Source edits don't invalidate the dependency layer.

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

For Poetry, replace with:

dockerfile
COPY pyproject.toml poetry.lock ./
RUN pip install poetry && \
    poetry config virtualenvs.create false && \
    poetry install --no-interaction --no-root --only main

For uv (rapidly gaining adoption):

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

Multi-stage for compiled dependencies

If you need gcc, g++, or other build tools to compile a wheel (some C extensions), do it in a build stage and copy just the installed packages into the runtime stage:

dockerfile
# Build stage
FROM python:3.13-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
      gcc g++ libpq-dev && \
    rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install --user --no-cache-dir -r requirements.txt

# Runtime stage
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
      libpq5 && \
    rm -rf /var/lib/apt/lists/*
COPY --from=build /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
WORKDIR /app
COPY . .
USER 1000
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "myapp.wsgi:application"]

pip install --user installs into /root/.local; the runtime stage copies just that directory plus the runtime libraries (libpq5 for Postgres clients, libffi8 for some crypto libs, etc.). Build toolchain is left behind.

Run as non-root

Python's base images don't ship a non-root user, so either pass USER 1000 (any unprivileged UID works) or RUN useradd -m app && USER app. The simple form:

dockerfile
USER 1000

For paths that need to be writable by that UID, set permissions before the USER switch:

dockerfile
RUN mkdir -p /app/logs && chown -R 1000:1000 /app
USER 1000

Django: collectstatic in the build, migrate at startup

dockerfile
# In Dockerfile:
RUN python manage.py collectstatic --noinput

# At runtime (in your entrypoint or compose):
python manage.py migrate
gunicorn -w 4 -b 0.0.0.0:8000 myapp.wsgi:application

collectstatic runs at build time so the image ships with the gathered static files. migrate runs at startup so it has a database to talk to (which doesn't exist at build time).

.dockerignore for Python

code
__pycache__
*.pyc
*.pyo
.venv
venv
.env
.env.local
*.log
.git
.gitignore
.pytest_cache
.mypy_cache
.ruff_cache
htmlcov
.coverage
Dockerfile
.dockerignore

Skipping __pycache__ and *.pyc is critical — those are platform-specific and shouldn't ship to the image.

Compose: Python + Postgres

yaml
services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://postgres:hello@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: hello
      POSTGRES_DB: myapp
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pg-data:

Common pitfalls

  • No PYTHONUNBUFFERED. Logs don't appear in docker logs until the buffer flushes. Always set it.
  • Alpine + scientific packages. NumPy / SciPy / Pandas don't ship musl wheels; pip falls back to source builds that take 10+ minutes and may fail. Use slim for scientific Python.
  • Copying source before pip install. Every source edit busts the dependency cache.
  • pip install without --no-cache-dir. The wheel cache stays in the image. Set the env var or pass the flag.
  • Forgetting to install libpq5 (or similar) in the runtime stage. If you used psycopg2 (compiled against libpq) in the build stage and didn't install the runtime library in the final stage, you'll get an import error on first start.
  • Running as root. Add USER 1000.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerPythonDjangoFlaskFastAPIDockerfileDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

H

How to Dockerize a PHP / Laravel App

A Dockerfile for PHP-FPM + Nginx, the Composer build step, OPcache config, and the Laravel storage/cache permission flip that catches every PHP developer once.

H

How to Dockerize a Node.js App

A Dockerfile for a real Node.js app: multi-stage build, npm ci for deterministic dependencies, the node_modules-volume trick that makes bind-mounted source fast on Mac, and the non-root user that most tutorials skip.