Skip to content

Dockerizing Flask App

Docker packages your app and its dependencies into a container.

Benefits:

  • consistent environments
  • easier deploys
  • simpler CI/CD
dockerfile
FROM python:3.12-slim
 
WORKDIR /app
 
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
COPY . .
 
ENV PORT=8000
 
CMD ["gunicorn", "wsgi:app", "--bind", "0.0.0.0:8000"]
  • Use Gunicorn inside the container (not flask run)
  • Expose port via platform settings
  • Store secrets via environment variables, not baked into the image
  • add a non-root user
  • use multi-stage builds
  • add health checks
  • pin dependencies
diagram Diagram mermaid
Dockerfile
FROM python:3.13-slim
 
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
 
WORKDIR /app
 
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
COPY . .
 
RUN useradd --create-home appuser && chown -R appuser /app
USER appuser
 
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]

Five decisions in that file are load-bearing:

  • COPY requirements.txt before COPY . . — layers are cached by the files they depend on. Copying the code first means every source edit reinstalls every dependency. This ordering is the single biggest build-time win available.
  • PYTHONUNBUFFERED=1 — without it, your logs sit in a buffer and a crashed container takes its most useful output with it.
  • USER appuser — a container process runs as root by default. A vulnerability then runs as root too.
  • --bind 0.0.0.0:8000 — binding to 127.0.0.1 inside a container means nothing outside it can connect, which looks exactly like a broken app.
  • gunicorn, not flask run — the development server prints a warning telling you not to do this, and it is right.

.dockerignore matters more than people expect

Section titled “.dockerignore matters more than people expect”
.dockerignore
__pycache__/
*.pyc
.env
.git/
venv/

Everything not excluded is sent to the build daemon and can end up in a layer. A .env copied into an image is a credential shipped to anyone who can pull it — and docker history will show it even if a later layer deletes the file.

commands
docker build -t flask-demo:v1 .
docker run --rm -p 8000:8000 -e APP_NAME=prod flask-demo:v1
docker run --rm -p 8000:8000 --env-file .env flask-demo:v1

-p 8000:8000 maps host to container. --env-file supplies configuration without baking it in, which is the containerised equivalent of everything on the .env page.

workers
gunicorn --workers 2 --bind 0.0.0.0:8000 app:app

The usual starting point is (2 x CPU cores) + 1, then measure. Two things worth knowing before you tune it:

  • Each worker is a separate process with its own memory, and its own copy of anything in-process — which is why an in-memory rate limiter or cache stops working correctly the moment you have more than one.
  • Containers are often limited to a fraction of a CPU. Reading the host’s core count and sizing from that can start far more workers than the container can run.
sketch Layer caching, and what a rebuild costs p5.js
Docker reuses a layer whose inputs have not changed. Copying requirements before the code is what keeps dependency installation cached.
pch.quizTag pch.quizDefaultTitle
  1. Why does the Dockerfile COPY requirements.txt and install before COPY . .?

    pch.quizShowAnswer

    B — layers are cached by their inputs, so copying code first would reinstall every dependency on any source edit — Editing one line of app.py invalidates every layer after the COPY. With the dependency install above it, that expensive layer stays cached.

  2. Why must gunicorn bind to 0.0.0.0 rather than 127.0.0.1 inside a container?

    pch.quizShowAnswer

    B — binding to loopback accepts connections only from inside the container, so a mapped port appears dead from outside — The symptom is an app that looks broken from the host while the container reports it is serving fine.

  3. Why should .env be listed in .dockerignore?

    pch.quizShowAnswer

    B — otherwise it is copied into a layer, shipping credentials to anyone who can pull the image — visible in docker history even if deleted later — Supply configuration at run time with -e or --env-file instead of baking it into the image.

  4. Why does an in-memory rate limiter or cache misbehave once gunicorn runs several workers?

    pch.quizShowAnswer

    B — each worker is a separate process with its own copy, so counters are per-worker rather than global — With four workers a limit of 100 per hour effectively allows up to 400. Shared state needs a shared backend such as Redis.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading