Dockerize a Flask Application

Khimananda Oli 9 min read Programming and Languages
Dockerize a Flask Application

By Khimananda Oli | Last reviewed: August 2026

You want to Dockerize a Flask application that actually survives production traffic without leaking secrets or bloating your CI pipeline. Most tutorials stop at a naive single-stage Dockerfile that runs as root and ships gigabytes of build tools into the final image. This guide covers the complete workflow: writing a secure multi-stage Dockerfile, configuring Gunicorn correctly, managing dependencies deterministically, and integrating observability from day one.

How Do You Structure a Project to Dockerize a Flask Application?

Before writing any Docker commands, your project layout must support reproducible builds. A common mistake is placing the Dockerfile inside a subdirectory while referencing parent paths, which breaks Docker’s build context and forces you to copy unnecessary files. For teams working in Nepal or globally, where bandwidth can be inconsistent, keeping the build context small prevents painful upload timeouts during CI.

Your repository should follow this structure to ensure clean layer caching and security isolation:

my-flask-app/
├── app/
│   ├── __init__.py
│   ├── routes.py
│   └── config.py
├── tests/
├── Dockerfile
├── .dockerignore
├── requirements.txt
├── gunicorn.conf.py
└── wsgi.py

The .dockerignore file is as critical as the Dockerfile itself. Without it, Docker copies your .git directory, local virtual environments, and IDE configs into the build context. This not only slows down builds but risks embedding sensitive git history or credentials into the image layers. Always exclude version control, documentation, and local development artifacts:

# .dockerignore
.git
.gitignore
.env
.venv
__pycache__
*.md
tests/
.dockerignore
Dockerfile

This preparation step aligns with the principles discussed in containerizing applications from scratch, where build context hygiene directly impacts both security posture and deployment velocity. When you Dockerize a Flask application with this structure, each layer becomes predictable and cacheable.

Source Codeapp/requirements.txtwsgi.pygunicorn.conf.py.env (EXCLUDED).git (EXCLUDED).dockerignore FilterRemoves secrets, venv,tests, docs, cache✓ Clean Build ContextDocker BuildMulti-stage compileDependency installNon-root user setupGunicorn entrypoint→ Production Image
Clean project structure ensures only necessary files enter the Docker build context when you Dockerize a Flask application

What Is the Best Multi-Stage Dockerfile to Dockerize a Flask Application?

A single-stage Dockerfile installs compilers, headers, and development libraries that remain in the final image. This creates three problems: larger attack surface, slower pulls over limited connections, and higher storage costs in registries. Multi-stage builds solve this by separating the build environment from the runtime environment.

Production-Ready Multi-Stage Dockerfile

This Dockerfile uses Python 3.12 slim as the base, compiles dependencies in a builder stage, and copies only the installed packages and application code into a minimal runtime image. It runs as a non-root user named appuser and exposes port 8000 for Gunicorn:

# Stage 1: Builder
FROM python:3.12-slim AS builder

WORKDIR /build

# Install build dependencies for compiled packages
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim AS runtime

# Install only runtime libraries (no compilers)
RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq5 curl && \
    rm -rf /var/lib/apt/lists/*

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application code
COPY wsgi.py gunicorn.conf.py ./
COPY app/ ./app/

# Set ownership and switch user
RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

CMD ["gunicorn", "--config", "gunicorn.conf.py", "wsgi:app"]

Why Each Layer Matters

  • --prefix=/install: Isolates pip packages in the builder so they can be copied cleanly without polluting system paths.
  • libpq5 vs libpq-dev: The runtime only needs the shared library (libpq5), not the development headers (libpq-dev). This alone saves ~15MB.
  • curl for healthchecks: Docker’s HEALTHCHECK requires a binary inside the container. Curl is lightweight and universally available in slim images.
  • Non-root user: If an attacker exploits your Flask app, they cannot modify system binaries or escalate privileges easily. This is mandatory for SOC 2 and ISO 27001 compliance.

For teams comparing database options alongside containerization, understanding how MariaDB versus MySQL affects driver dependencies helps you choose the right apt-get packages in the builder stage. When you Dockerize a Flask application with PostgreSQL, libpq-dev is required; for MySQL/MariaDB, substitute default-libmysqlclient-dev.

How Do You Configure Gunicorn When You Dockerize a Flask Application?

Flask’s built-in development server is single-threaded and not designed for concurrent requests. Running it in production inside a container will cause request queuing, timeout errors under load, and potential denial-of-service vulnerabilities. Gunicorn acts as a production-grade WSGI HTTP server that manages worker processes and handles connection buffering.

Gunicorn Configuration File

Create gunicorn.conf.py at your project root. This configuration balances memory usage against concurrency for typical container resource limits:

# gunicorn.conf.py
import multiprocessing

bind = "0.0.0.0:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 120
graceful_timeout = 30
keepalive = 5
accesslog = "-"
errorlog = "-"
loglevel = "info"

# Preload app to share memory between workers (copy-on-write)
preload_app = True

Worker Count and Memory Trade-offs

The formula (2 × CPU cores) + 1 is Gunicorn’s recommended starting point for synchronous workers. In a container with 2 vCPUs, this yields 5 workers. Each sync worker handles one request at a time, so 5 workers = 5 concurrent requests. If your Flask app performs I/O-bound operations (database queries, API calls), consider gevent or uvicorn.workers.UvicornWorker for async support, but test thoroughly—async changes error handling semantics.

ConfigurationSuitable ForMemory per WorkerConcurrency Model
sync (default)CPU-bound tasks, simple APIs50–100 MB1 request/worker
geventI/O-bound, high concurrency30–70 MBGreenlets, thousands of connections
uvicorn.workerASGI apps, WebSocket support60–120 MBAsync event loop
gthreadMixed workload, legacy code60–100 MBThreads per worker

The preload_app = True directive loads your Flask application once before forking workers. On Linux, this leverages copy-on-write memory sharing, reducing total RAM consumption by 30–50% compared to loading the app independently in each worker. However, preloading means database connections created at import time are shared across forks, which causes errors. Always initialize connections lazily or use connection pooling libraries like SQLAlchemy’s pool_pre_ping.

Gunicorn MasterPID 1 · Manages WorkersWorker 1Sync · 1 reqWorker 2Sync · 1 reqWorker 3Sync · 1 reqWorker 4Sync · 1 reqWorker 5Sync · 1 reqFlask App InstanceFlask App InstanceFlask App InstanceFlask App InstanceFlask App Instancepreload_app=True → Copy-on-Write Memory Sharing5 Workers = 5 Concurrent Requests (sync mode)
Gunicorn master forks sync workers with preloaded Flask app for memory-efficient concurrency when you Dockerize a Flask application

How Do You Manage Dependencies Securely When You Dockerize a Flask Application?

Using pip install -r requirements.txt without pinned versions produces non-reproducible builds. A dependency update upstream can introduce breaking changes or vulnerabilities between builds of the same commit. In audit scenarios (SOC 2, ISO 27001), you must demonstrate that the exact same artifact was tested and deployed.

Pinning and Hashing Dependencies

Generate a fully pinned requirements file with hashes using pip-tools:

# Generate pinned requirements with hashes
pip-compile --generate-hashes --output-file=requirements.txt requirements.in

# Example output (requirements.txt)
flask==3.1.1 \
    --hash=sha256:abc123... \
    --hash=sha256:def456...
gunicorn==23.0.0 \
    --hash=sha256:ghi789...
psycopg2-binary==2.9.10 \
    --hash=sha256:jkl012...

The --generate-hashes flag ensures pip verifies each package’s integrity during installation. If a package is tampered with or replaced upstream, the build fails immediately rather than silently installing malicious code. This is defense-in-depth for your supply chain.

Separate development dependencies into requirements-dev.in (pytest, black, mypy). Never include them in the production Dockerfile. When you Dockerize a Flask application, the runtime image should contain zero testing or linting tools. For deeper guidance on structuring observability alongside dependencies, review structured logging best practices to ensure your logging library versions are also pinned and compatible.

How Do You Run and Debug a Container After You Dockerize a Flask Application?

Building the image is only half the work. You need reliable commands to test locally, inspect failures, and verify the container behaves identically to production.

Build and Run Commands

  1. Build the image:
    docker build -t my-flask-app:latest .
  2. Run locally with environment variables:
    docker run -p 8000:8000 \
      -e FLASK_ENV=production \
      -e DATABASE_URL=postgresql://user:pass@host:5432/db \
      --rm my-flask-app:latest
  3. Verify health endpoint:
    curl http://localhost:8000/health
  4. Inspect running container logs:
    docker logs <container_id> --follow
  5. Shell into container for debugging:
    docker exec -it <container_id> /bin/bash

Common Pitfalls and Fixes

  • "ModuleNotFoundError": Usually caused by incorrect WORKDIR or missing COPY instruction. Verify your app directory structure matches the COPY paths exactly.
  • "Permission denied": The non-root user lacks write access to a directory. Never run as root to fix this; instead, adjust RUN chown or use tmpfs mounts for writable paths.
  • Healthcheck failing: Ensure the /health route exists in your Flask app and returns HTTP 200. Test the curl command manually inside the container first.
  • Slow startup: Preloading large apps takes time. Increase --start-period in HEALTHCHECK to avoid premature restarts.

When debugging performance issues after you Dockerize a Flask application, integrate metrics early. The techniques in Prometheus metrics monitoring fundamentals apply directly to containerized Flask apps—expose a /metrics endpoint using prometheus_flask_instrumentator and scrape it from your monitoring stack.

Naive Single-StageImage Size: ~950 MBRuns as: root (HIGH RISK)Server: Flask dev serverDependencies: UnpinnedHealthcheck: NoneBuild Tools: Included in runtime✗ Not Production ReadyMulti-Stage ProductionImage Size: ~180 MBRuns as: appuser (LEAST PRIVILEGE)Server: Gunicorn (sync/gevent)Dependencies: Pinned + HashedHealthcheck: curl /healthBuild Tools: Excluded from runtime✓ Audit-Ready & Optimized
Side-by-side comparison of naive versus production approaches when you Dockerize a Flask application highlighting security and efficiency gains

Next Steps After You Dockerize a Flask Application

Containerizing your Flask app is the foundation, not the destination. From here, push your image to a private registry (ECR, GitLab Container Registry, or Harbor), scan it with Trivy for CVEs, and deploy via Kubernetes or ECS with resource limits matching your Gunicorn worker calculations. Integrate OpenTelemetry for distributed tracing before traffic grows complex enough to make debugging impossible without it.

If your team needs help designing production-grade container workflows, passing compliance audits, or optimizing cloud costs for Python workloads, reach out to discuss your infrastructure. Whether you're building from Kathmandu or serving global users, getting the containerization layer right prevents costly rework downstream.

Frequently Asked Questions

Use python:3.12-slim-bookworm for most Flask apps. It balances small size with glibc compatibility needed by dependencies like psycopg2 or numpy. Avoid alpine unless you specifically need musl, as compiling C extensions often fails or takes significantly longer during builds.

Add EXPOSE 5000 and run gunicorn binding to 0.0.0.0:5000. Never bind to localhost inside containers.

Gunicorn is generally preferred for its simpler configuration and active maintenance. Use gunicorn -w 4 -b 0.0.0.0:5000 app:app in your CMD instruction. Reserve uWSGI only if you require specific protocol features or existing tuning profiles that Gunicorn cannot replicate easily.

Pass secrets at runtime using docker compose secrets or Kubernetes secrets, never bake them into images. Reference variables via os.environ in Flask config. Use .env files only for local development and ensure they remain listed in .dockerignore to prevent accidental inclusion.

Containers use isolated networks, so localhost refers to the container itself. Change database host to the Docker Compose service name like db or postgres. Ensure both services share the same network definition and verify the database container accepts connections on port 5463 before debugging Flask connection strings.

Exclude .git, pycache, .env, venv, tests, and markdown documentation. This prevents cache invalidation from irrelevant file changes and stops sensitive credentials from entering image layers. Keeping context small also accelerates build times significantly, especially when copying source code early in multi-stage builds.

Mount source volume and set FLASK_DEBUG=1 in compose.yaml. Avoid this in production.

Implement multi-stage builds separating dependency installation from runtime. Copy only site-packages and application code to the final slim stage. Remove pip cache with --no-cache-dir during install. Combine RUN commands to minimize layers and delete build tools like gcc after compiling necessary C extensions.

Create a lightweight /health route returning 200 OK without database queries. Configure HEALTHCHECK in Dockerfile using curl against this endpoint. Set appropriate intervals and retries matching your orchestrator expectations. Avoid heavy checks here; reserve deep dependency verification for separate readiness probes in Kubernetes deployments.

Pin exact versions in requirements.txt using pip freeze or pip-compile. Install with pip install -r requirements.txt --no-cache-dir. Never use loose version specifiers in production images. Regenerate lock files intentionally and commit them to version control to guarantee identical environments across CI, staging, and production deployments.

No. The built-in server lacks concurrency, security hardening, and graceful shutdown handling required for production traffic. Always use a WSGI server like Gunicorn behind Nginx or Traefik. Development servers are acceptable only during local debugging with mounted volumes and explicit debug flags enabled in non-exposed containers.

Write logs to stdout/stderr instead of files so Docker captures them natively. Configure Python logging with StreamHandler and JSON formatter for structured output. Avoid file handlers inside containers since ephemeral storage loses data on restart. Let your orchestration platform aggregate and persist logs externally through standard container logging drivers.

Running as root creates files owned by UID 0 that non-root users cannot modify later. Create a dedicated app user in Dockerfile with USER instruction before copying application code. Set ownership explicitly during COPY operations. Match host UID during development volume mounts to prevent write failures on mounted directories.

Copy requirements.txt before application source code. Install dependencies in a separate RUN step immediately after. This ensures expensive pip installs reuse cached layers when only application code changes. Invalidate dependency cache intentionally by bumping a comment or argument when requirements genuinely update to avoid stale packages.

Separate them. Single responsibility simplifies scaling, updates, and debugging.