How to Package a Flask Application Inside a Docker Container: Complete Deployment Guide

How to Package a Flask Application Inside a Docker Container

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 builder stage, then only the resulting /opt/venv directory is copied into the final image. This keeps pip‘s build cache and any compiler toolchains needed for wheel building out of the final image.
  • Non-root userappuser runs 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 3 is a reasonable starting point (a common formula is (2 x CPU cores) + 1), and --timeout 60 prevents 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_KEY or 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-slim reach your running image.
  • Scan the built image: docker scout cves flask-demo:1.0.0 or trivy image flask-demo:1.0.0.

Troubleshooting Common Issues

  • “Connection refused” from the host — check the app is bound to 0.0.0.0, not 127.0.0.1, and that your -p/ports: mapping matches the actual EXPOSE/listening port.
  • Healthcheck stuck in “unhealthy” — exec into the container (docker exec -it flask-demo bash) and manually curl /health to 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 --timeout temporarily to confirm, then fix the underlying slow path or move to async workers (gevent/gthread worker 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/
Total
0
Shares

Leave a Reply

Previous Post
How to Save Images and Docker Containers as Tar Files for Sharing

How to Save Images and Docker Containers as Tar Files for Sharing: Export, Backup, and Transfer

Next Post
How to Optimize Your Dockerfile

How to Optimize Your Dockerfile: Best Practices for Smaller, Faster, and More Secure Images

Related Posts