Flask is one of the first frameworks I recommend to anyone learning Python web development, and it’s also one of the frameworks I containerize most often for clients — small, unopinionated, and easy to reason about at every layer. But there’s a real gap between “Flask app that runs with flask run” and “Flask app running safely and efficiently inside a container in production.” In this guide, I’ll walk through that entire journey: from a basic Flask app, to a production-grade Dockerfile, to Compose-based local development, to a Kubernetes deployment.
Setting Up the Flask Application
Let’s start with a minimal but realistic Flask app.
flask-docker-demo/
├── app.py
├── requirements.txt
├── Dockerfile
├── .dockerignore
└── docker-compose.yml
app.py:
from flask import Flask, jsonify
import os
app = Flask(__name__)
@app.route("/")
def index():
return jsonify(message="Hello from a containerized Flask app!")
@app.route("/health")
def health():
return jsonify(status="ok"), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))
requirements.txt:
flask==3.0.3
gunicorn==22.0.0
Note that I’m including gunicorn from the start — Flask’s built-in development server (flask run / app.run()) is explicitly not designed for production traffic, and every production Flask container should run behind a proper WSGI server.
Why Not Use Flask’s Built-In Server in Production
Flask’s development server is single-threaded by default, doesn’t handle concurrent requests efficiently, and lacks the process-management features (worker recycling, graceful timeouts, proper signal handling) you need for a resilient production deployment. Gunicorn (or uWSGI) solves all of that, which is why virtually every real-world Flask container runs Gunicorn as its entrypoint instead.
Writing the Dockerfile
Here’s a production-grade, multi-stage Dockerfile:
# syntax=docker/dockerfile:1
# ---- Build stage: install dependencies into a virtualenv ----
FROM python:3.12-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
# ---- Final stage: minimal runtime image ----
FROM python:3.12-slim
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --chown=appuser:appuser . .
USER appuser
ENV PORT=5000
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "3", "--timeout", "60", "app:app"]
Walking through the key decisions here:
- Multi-stage build — dependencies are installed into a virtualenv in the
builderstage, then only the resulting/opt/venvdirectory is copied into the final image. This keepspip‘s build cache and any compiler toolchains needed for wheel building out of the final image. - Non-root user —
appuserruns the application, not root. HEALTHCHECK— lets Docker (and orchestrators that respect it) know whether the app is actually serving traffic, not just whether the process is alive.- Gunicorn as the entrypoint —
--workers 3is a reasonable starting point (a common formula is(2 x CPU cores) + 1), and--timeout 60prevents hung requests from blocking a worker indefinitely.
The .dockerignore File
__pycache__
*.pyc
.venv
venv/
.git
.gitignore
.env
*.md
Dockerfile
.dockerignore
tests/
Building and Running the Image
docker build -t flask-demo:1.0.0 .
Expected output (abbreviated):
[+] Building 12.4s (14/14) FINISHED
=> [builder 1/4] FROM docker.io/library/python:3.12-slim
=> [builder 3/4] RUN python -m venv /opt/venv
=> [builder 4/4] RUN pip install --no-cache-dir -r requirements.txt
=> [stage-1 3/6] RUN useradd --create-home --shell /bin/bash appuser
=> [stage-1 5/6] COPY --from=builder /opt/venv /opt/venv
=> [stage-1 6/6] COPY --chown=appuser:appuser . .
=> exporting to image
=> => naming to docker.io/library/flask-demo:1.0.0
Run it:
docker run -d --name flask-demo -p 8080:5000 flask-demo:1.0.0
Test it:
curl http://localhost:8080/
{"message":"Hello from a containerized Flask app!"}
curl http://localhost:8080/health
{"status":"ok"}
Check the health status Docker itself is tracking:
docker inspect --format='{{json .State.Health.Status}}' flask-demo
"healthy"
Configuring the App with Environment Variables
Real Flask apps need configuration — database URLs, secret keys, feature flags. Never hardcode these; inject them at runtime:
import os
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-change-me")
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///local.db")
DEBUG = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
app.config.from_object(Config)
docker run -d --name flask-demo \
-p 8080:5000 \
-e SECRET_KEY="$(openssl rand -hex 32)" \
-e DATABASE_URL="postgresql://user:pass@db:5432/appdb" \
flask-demo:1.0.0
Local Development with Docker Compose
For local development, I usually add a Postgres service alongside the Flask app and mount source code as a volume so I get live reload without rebuilding the image on every change:
services:
web:
build: .
ports:
- "8080:5000"
volumes:
- .:/app
environment:
FLASK_DEBUG: "true"
DATABASE_URL: "postgresql://appuser:apppass@db:5432/appdb"
SECRET_KEY: "local-dev-secret"
depends_on:
db:
condition: service_healthy
command: ["gunicorn", "--bind", "0.0.0.0:5000", "--reload", "--workers", "1", "app:app"]
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
docker compose up -d
[+] Running 3/3
✔ Network flask-docker-demo_default Created
✔ Container flask-docker-demo-db-1 Healthy
✔ Container flask-docker-demo-web-1 Started
Check logs to confirm Gunicorn started cleanly:
docker compose logs web
web-1 | [2026-07-29 10:12:03 +0000] [1] [INFO] Starting gunicorn 22.0.0
web-1 | [2026-07-29 10:12:03 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000
web-1 | [2026-07-29 10:12:03 +0000] [8] [INFO] Booting worker with pid: 8
Deploying to Kubernetes
A minimal Deployment and Service for the same image:
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-demo
spec:
replicas: 3
selector:
matchLabels:
app: flask-demo
template:
metadata:
labels:
app: flask-demo
spec:
containers:
- name: flask-demo
image: registry.example.com/my-team/flask-demo:1.0.0
ports:
- containerPort: 5000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: flask-demo-secrets
key: database-url
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: flask-demo-secrets
key: secret-key
readinessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
---
apiVersion: v1
kind: Service
metadata:
name: flask-demo
spec:
selector:
app: flask-demo
ports:
- port: 80
targetPort: 5000
type: ClusterIP
Apply it:
kubectl apply -f flask-demo-deployment.yaml
kubectl get pods -l app=flask-demo
NAME READY STATUS RESTARTS AGE
flask-demo-6f9d8c7b5d-a1b2c 1/1 Running 0 20s
flask-demo-6f9d8c7b5d-d4e5f 1/1 Running 0 20s
flask-demo-6f9d8c7b5d-g7h8i 1/1 Running 0 20s
Container Architecture and Networking Notes
A common early mistake is binding Flask/Gunicorn to 127.0.0.1 instead of 0.0.0.0. Inside a container’s network namespace, 127.0.0.1 refers only to the container itself — Docker’s port mapping (-p 8080:5000) forwards traffic to the container’s network interface, not to its loopback interface, so binding to loopback makes the app completely unreachable from outside the container even though it “works” if you exec into the container and curl it locally. Always bind application servers to 0.0.0.0 inside containers.
Security Best Practices for Flask Containers
- Run as a non-root user (shown above).
- Never bake
SECRET_KEYor database credentials into the image — inject via environment variables or, better, Kubernetes Secrets/Docker secrets. - Disable Flask’s debug mode in production (
FLASK_DEBUG=false) — debug mode exposes an interactive debugger that can lead to remote code execution if internet-facing. - Keep the base image patched; rebuild regularly so security fixes in
python:3.12-slimreach your running image. - Scan the built image:
docker scout cves flask-demo:1.0.0ortrivy image flask-demo:1.0.0.
Troubleshooting Common Issues
- “Connection refused” from the host — check the app is bound to
0.0.0.0, not127.0.0.1, and that your-p/ports:mapping matches the actualEXPOSE/listening port. - Healthcheck stuck in “unhealthy” — exec into the container (
docker exec -it flask-demo bash) and manually curl/healthto see whether it’s a networking issue or an application error. - Gunicorn workers keep dying (
WORKER TIMEOUT) — likely a slow database query or synchronous blocking call; increase--timeouttemporarily to confirm, then fix the underlying slow path or move to async workers (gevent/gthreadworker class). - Permission denied writing to a mounted volume — the non-root
appuser‘s UID may not match the host directory’s ownership; align UIDs or adjust the volume’s permissions.
Summary
Packaging a Flask app for containers is straightforward once you internalize a few non-negotiables: never ship the Flask development server to production, always run behind Gunicorn (or an equivalent WSGI server), bind to 0.0.0.0, run as a non-root user, and inject configuration through environment variables rather than hardcoding it. Layer in multi-stage builds to keep the image lean, health checks so orchestrators know the app’s real state, and resource limits in Kubernetes so a misbehaving pod can’t take down its node.
References
- Flask Official Documentation: https://flask.palletsprojects.com/
- Gunicorn Documentation: https://docs.gunicorn.org/
- Docker Documentation — Dockerize a Flask Application: https://docs.docker.com/language/python/
- Kubernetes Documentation — Configure Liveness, Readiness Probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Docker Compose Documentation: https://docs.docker.com/compose/
