Dockerizing Flask App
Docker packages your app and its dependencies into a container.
Benefits:
- consistent environments
- easier deploys
- simpler CI/CD
Minimal Dockerfile (example)
Section titled “Minimal Dockerfile (example)”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
Common improvements
Section titled “Common improvements”- add a non-root user
- use multi-stage builds
- add health checks
- pin dependencies
What a container has to contain
Section titled “What a container has to contain”flowchart TD B["base image: python:3.13-slim"] --> D["dependencies from requirements.txt"] D --> C["your application code"] C --> U["a non-root user"] U --> R["CMD: a production WSGI server
gunicorn, bound to 0.0.0.0"] R --> P["EXPOSE the port the platform will map"]
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.txtbeforeCOPY . .— 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 to127.0.0.1inside 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”__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.
Building and running
Section titled “Building and running”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.
Sizing the worker count
Section titled “Sizing the worker count”gunicorn --workers 2 --bind 0.0.0.0:8000 app:appThe 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.
See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
Why does the Dockerfile COPY requirements.txt and install before COPY . .?
Editing one line of app.py invalidates every layer after the COPY. With the dependency install above it, that expensive layer stays cached.
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.
-
Why must gunicorn bind to 0.0.0.0 rather than 127.0.0.1 inside a container?
The symptom is an app that looks broken from the host while the container reports it is serving fine.
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.
-
Why should .env be listed in .dockerignore?
Supply configuration at run time with -e or --env-file instead of baking it into the image.
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.
-
Why does an in-memory rate limiter or cache misbehave once gunicorn runs several workers?
With four workers a limit of 100 per hour effectively allows up to 400. Shared state needs a shared backend such as Redis.
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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading