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

How to Package a Flask Application Inside a Docker Container

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:

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

Troubleshooting Common Issues

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

Exit mobile version