Containerizing Your Application with Docker and PostgreSQL

Introduction: Consistency and Security with Containerization

In the previous chapters, you’ve built a robust Flask application, implementing secure authentication, input validation, and solid error handling. These are crucial steps for mitigating common web vulnerabilities. However, a secure application also needs a secure and consistent environment to run in. This is where containerization with Docker becomes indispensable.

Containerization solves the perennial “it works on my machine” problem by packaging your application and all its dependencies—from code to runtime, system tools, and libraries—into a single, isolated unit called a container. This isolation not only ensures consistent behavior across different environments but also inherently enhances security by limiting the attack surface and providing a predictable runtime. By the end of this chapter, your Flask application and its PostgreSQL database will be running in Docker containers, providing a portable, reproducible, and secure foundation for both development and production.

Project Overview: Secure Flask App Containerization

This chapter focuses on transforming our previously developed Flask application into a fully containerized system. The goal is to encapsulate the application and its database into Docker containers, orchestrated by Docker Compose.

Finished Artifact: A multi-container Docker setup where the Flask web application and a PostgreSQL database run as isolated, interconnected services.

Target User: Any developer or operations engineer needing to run, test, or deploy our secure Flask application.

Success Criteria:

  • The Flask application container builds successfully from a Dockerfile.
  • The PostgreSQL database container initializes and persists data.
  • The Flask application successfully connects to and interacts with the PostgreSQL database.
  • The entire application stack can be brought up and down consistently with a single Docker Compose command.
  • Sensitive configurations (like database credentials) are managed via environment variables, not hardcoded.

Tech Stack & Versions

For this chapter, we will leverage the following technologies:

  • Python 3.12: The core language for our Flask application.
  • Flask 3.0.3: Our web framework.
  • PostgreSQL 16.3: The relational database for data persistence.
  • Docker Desktop (latest stable release): The containerization platform.
  • Docker Compose 3.8: For orchestrating multi-container applications.
  • Gunicorn: (Mentioned for production, but flask run will be used for development in CMD).

Note on versions: As of 2026-07-13, the specified versions (Python 3.12, Flask 3.0.3, PostgreSQL 16.3) represent projected stable releases and are used for consistency. For Docker Desktop, always use the latest stable release available at your time of installation.

Build Plan: Milestones for Containerization

We will achieve our containerization goal through the following incremental steps:

  1. Update Dependencies: Ensure requirements.txt includes necessary database drivers.
  2. Define Application Image: Create a Dockerfile to build the Flask application’s container image.
  3. Orchestrate Services: Write a docker-compose.yml file to define and link the Flask app and PostgreSQL database services.
  4. Adapt Configuration: Modify the Flask application to consume database credentials and other settings from environment variables.
  5. Verify Functionality: Build, run, and test the entire containerized stack locally.

Architecture Design: Dockerized Services

Our containerized architecture will consist of two primary services: our Flask web application and a PostgreSQL database. Docker Compose will manage these services, creating a private network for them to communicate securely, isolated from the host machine’s network.

Architecture Overview

flowchart TD User_Browser[User Browser] -->|HTTP Request| Docker_Host[Docker Host] subgraph Docker_Network["Docker Network"] Flask_App[Flask App Container] PostgreSQL_DB[PostgreSQL DB Container] end Docker_Host --> Flask_App Flask_App -->|DB Queries| PostgreSQL_DB PostgreSQL_DB -->|Data Response| Flask_App

Key Components Explained

  • Dockerfile: This text file contains a series of instructions that Docker uses to build a Docker image. For our Flask application, it specifies the base Python environment, copies our code, installs dependencies, and defines how the application should start.
  • docker-compose.yml: A YAML file that defines a multi-container Docker application. It allows us to configure all services (our Flask app and PostgreSQL DB), their network interactions, persistent storage (volumes), and environment variables in a single, readable file.
  • Docker Volumes: These are the preferred mechanism for persisting data generated by Docker containers. We’ll use a named volume to ensure our PostgreSQL database data is not lost when the database container is stopped, removed, or updated. This is crucial for data integrity.

Step-by-Step Implementation

Let’s begin the implementation. Ensure you have Docker Desktop installed and running on your system.

1. Prepare requirements.txt

First, ensure your requirements.txt file includes all necessary Python dependencies for your Flask application, especially the PostgreSQL database driver. For Python 3.10+, psycopg is the modern, official driver, while psycopg2-binary is commonly used for earlier versions or simpler setups. Given our projected Python 3.12, we’ll include psycopg.

File: requirements.txt

Flask==3.0.3
Werkzeug==3.0.1
Jinja2==3.1.3
python-dotenv==1.0.1
Flask-Bcrypt==1.0.1
Flask-SQLAlchemy==3.1.1 # Assuming you use Flask-SQLAlchemy for ORM
psycopg==3.1.18 # Official PostgreSQL driver for Python 3.10+
# Add any other project dependencies here

Explanation:

  • psycopg==3.1.18: This is the modern, pure Python PostgreSQL driver. We’re using a version projected to be stable and compatible with Python 3.12 by 2026-07-13. If you were using an older Python version or preferred the binary distribution, psycopg2-binary would be an alternative.

2. Create a Dockerfile for the Flask Application

Create a file named Dockerfile in the root of your project directory. This file defines how Docker builds the image for our Flask application.

File: Dockerfile

# Use an official Python runtime as a parent image
# python:3.12-slim-bullseye provides a lightweight, secure base.
FROM python:3.12-slim-bullseye

# Set the working directory in the container
WORKDIR /app

# Copy only the requirements file first. This allows Docker to cache this layer.
# If only application code changes, this layer won't be rebuilt, speeding up subsequent builds.
COPY requirements.txt .

# Install any needed packages specified in requirements.txt
# --no-cache-dir reduces image size. --upgrade pip ensures a recent pip version.
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application code into the container at /app
COPY . .

# Expose the port the app runs on. This is purely informational.
EXPOSE 5000

# Define environment variables for Flask.
# FLASK_APP points to our main application entry point.
# FLASK_RUN_HOST=0.0.0.0 makes the Flask development server accessible from outside the container.
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0

# Critical security measure: Run the application as a non-root user.
# Create a new user 'appuser' and set it as the default user for subsequent commands.
RUN adduser --disabled-password --gecos "" appuser
USER appuser

# Command to run the application. For development, we use 'flask run'.
# For production, replace with a WSGI server like Gunicorn:
# CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:create_app()"]
# Note: If using `create_app()` factory, ensure your Gunicorn command calls it correctly.
CMD ["flask", "run"]

Explanation of Decisions:

  • FROM python:3.12-slim-bullseye: We select a specific, slim Python image. slim variants are smaller, which reduces the attack surface and improves image download times. bullseye indicates the Debian distribution version.
  • WORKDIR /app: Establishes /app as the default working directory for subsequent commands within the container, keeping our container organized.
  • COPY requirements.txt . followed by RUN pip install: This is a Docker best practice. By copying requirements.txt separately, Docker can cache the dependency installation layer. If only your application code changes (not dependencies), this layer won’t be rebuilt, significantly speeding up build times.
  • RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt: Installs Python dependencies. --no-cache-dir prevents pip from storing downloaded packages, further reducing the final image size.
  • COPY . .: Copies the rest of your Flask application code into the /app directory inside the container.
  • EXPOSE 5000: Declares that the container listens on port 5000 at runtime. This is documentation for Docker and users; it does not actually publish the port.
  • ENV FLASK_APP=app.py and ENV FLASK_RUN_HOST=0.0.0.0: Essential environment variables for Flask to locate and run your application, making it accessible from outside the container.
  • RUN adduser ... && USER appuser: Production Awareness: This is a crucial security step. Running your application as a non-root user (appuser) inside the container minimizes potential damage if the container is compromised. The --disabled-password --gecos "" flags ensure a minimal user account.
  • CMD ["flask", "run"]: Defines the default command to execute when the container starts. For a production environment, this would be replaced with a production-ready WSGI server like Gunicorn for better performance and stability.

3. Create docker-compose.yml

Create a file named docker-compose.yml in the root of your project. This file defines our multi-container application, linking the Flask app and the PostgreSQL database.

File: docker-compose.yml

version: '3.8' # Specify the Docker Compose file format version

services:
  web:
    build: . # Build the image for this service using the Dockerfile in the current directory
    ports:
      - "5000:5000" # Map host port 5000 to container port 5000
    environment:
      # Database connection string. 'db' is the service name of our PostgreSQL container.
      # Hardcoding sensitive values here is for local development ONLY.
      DATABASE_URL: postgresql://user:password@db:5432/mydatabase
      FLASK_ENV: development # Set to 'production' for production deployments
      SECRET_KEY: your_super_secret_key_here # IMPORTANT: Replace with a strong, random key.
                                          # In production, use a secret management system.
    depends_on:
      - db # Ensures the 'db' service starts before 'web'. This is a best-effort dependency.
    volumes:
      - .:/app # Mount the current host directory into the container.
                # This enables live code changes during development without rebuilding the image.
                # REMOVE this line for production deployments to ensure immutable containers.
    # Production CMD (uncomment and replace CMD in Dockerfile if deploying to prod):
    # command: gunicorn -w 4 -b 0.0.0.0:5000 app:create_app() # Example for a Flask app factory

  db:
    image: postgres:16.3-alpine # Use the official PostgreSQL image (version 16.3-alpine for lightweight)
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - db_data:/var/lib/postgresql/data # Persist database data to a named Docker volume

volumes:
  db_data: # Define the named volume for PostgreSQL data persistence

Explanation of Decisions:

  • version: '3.8': Specifies the Docker Compose file format version. Version 3.8 offers various improvements and features.
  • web service:
    • build: .: Instructs Docker Compose to build an image for this service using the Dockerfile in the current directory.
    • ports: "5000:5000": Maps port 5000 on your host machine to port 5000 inside the web container. This allows you to access your Flask application from your browser via http://localhost:5000.
    • environment: This section is crucial for securely passing configuration values into your containers.
      • DATABASE_URL: The connection string for PostgreSQL. Notice db is used as the host; Docker Compose automatically creates a network that allows services to resolve each other by their names.
      • FLASK_ENV: Set to development for local testing. This should be production for actual deployments.
      • SECRET_KEY: CRITICAL SECURITY WARNING: While convenient for development, never hardcode sensitive credentials like SECRET_KEY directly in docker-compose.yml for production. In a real-world production scenario, this would be injected via a secret management system (e.g., Kubernetes Secrets, AWS Secrets Manager, Azure Key Vault).
    • depends_on: - db: This ensures that the db service is started before the web service. While depends_on doesn’t wait for the database to be fully “ready,” it helps with the startup order.
    • volumes: - .:/app: Important for development only. This mounts your local project directory into the /app directory inside the web container. Any changes you make to your code locally will immediately reflect in the running container without needing to rebuild the image. For production, you must remove this line and rely solely on the COPY . . command in the Dockerfile to ensure the container image is immutable and contains all necessary code.
  • db service:
    • image: postgres:16.3-alpine: Uses the official PostgreSQL image. The 16.3 tag (projected for 2026-07-13) ensures a specific version, and -alpine provides a very small, secure base image, reducing the attack surface.
    • environment: Sets the initial database name, user, and password for the PostgreSQL instance. Again, these are defaults for development; never hardcode sensitive credentials in production.
    • volumes: - db_data:/var/lib/postgresql/data: Mounts a named Docker volume (db_data) to the PostgreSQL data directory within the container. This ensures that your database’s data persists even if the db container is stopped, removed, or recreated, preventing data loss.
  • volumes: db_data:: Defines the named volume db_data that Docker manages.

4. Update Flask Application for Environment Variables

Modify your Flask application’s configuration to read database credentials and other sensitive settings from environment variables. This is a fundamental security practice that prevents sensitive data from being hardcoded into your application’s source code.

File: app/config.py (or similar configuration file)

import os

class Config:
    # Use os.getenv to fetch environment variables, providing a default for local development
    SECRET_KEY = os.getenv('SECRET_KEY', 'a_strong_default_dev_secret_key_for_local_use')
    
    # Database connection string from environment variable.
    # The fallback should ideally be for local development outside Docker Compose.
    SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'postgresql://user:password@localhost:5432/mydatabase_local')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    
    # Bcrypt salt rounds for password hashing. More rounds increase security but also CPU usage.
    BCRYPT_LOG_ROUNDS = 12

    # Secure session cookie configuration
    # SESSION_COOKIE_SECURE: True only over HTTPS (production)
    # SESSION_COOKIE_HTTPONLY: Prevent client-side JavaScript access
    # SESSION_COOKIE_SAMESITE: 'Lax' helps protect against CSRF
    SESSION_COOKIE_SECURE = os.getenv('FLASK_ENV') == 'production'
    SESSION_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SAMESITE = 'Lax'
    REMEMBER_COOKIE_SECURE = os.getenv('FLASK_ENV') == 'production'
    REMEMBER_COOKIE_HTTPONLY = True

    # Error handling to prevent leaking sensitive information in production
    # Set to True in development to see full tracebacks, False in production.
    PROPAGATE_EXCEPTIONS = os.getenv('FLASK_ENV') == 'development'

In your app/__init__.py (or your application’s main entry point), ensure you are loading this configuration class. Also, ensure that dotenv is not loaded if you are running inside Docker Compose, as Docker handles environment variables directly.

File: app/__init__.py (excerpt)

from flask import Flask
from flask_bcrypt import Bcrypt
from flask_sqlalchemy import SQLAlchemy
import os
# from dotenv import load_dotenv # COMMENT OUT or remove if running with Docker Compose

# load_dotenv() # Only uncomment this line if running locally WITHOUT Docker Compose
                # and you need to load .env file for environment variables.

db = SQLAlchemy()
bcrypt = Bcrypt()

def create_app():
    app = Flask(__name__)
    app.config.from_object('app.config.Config') # Load config from the Config class

    db.init_app(app)
    bcrypt.init_app(app)

    # Register blueprints (e.g., auth_bp for authentication routes)
    from .auth import auth_bp
    app.register_blueprint(auth_bp)

    # Add a simple route for health checks or basic testing
    @app.route('/health')
    def health_check():
        return {"status": "ok"}, 200

    return app

# If your app structure is simpler and you don't use create_app() factory:
# app = create_app() # Then you might directly run `flask run` on this 'app' instance

Why this matters: Using environment variables is the industry standard for configuring applications securely. It keeps sensitive data out of your codebase, allows for easy configuration changes between environments (development, staging, production) without rebuilding your Docker image, and integrates well with secret management systems in cloud deployments.

Testing & Verification: Launching Your Containerized App

With our Dockerfile and docker-compose.yml in place, it’s time to build and run our containerized application.

  1. Build and Start Containers: Navigate to your project’s root directory in your terminal. This is where your Dockerfile and docker-compose.yml reside. Then, run the following command:

    docker compose up --build
    • docker compose up: This command starts the services defined in your docker-compose.yml.
    • --build: This flag forces Docker Compose to rebuild the images if changes have been made to your Dockerfile or any build context files. It’s good practice to include it initially or after code changes.

    You will see extensive output as Docker downloads the PostgreSQL image (if not already cached) and builds your Flask application image. This initial process might take a few minutes. Look for messages indicating that the db and web services are starting up.

  2. Verify Application Access: Once the web service logs indicate that your Flask application is running (e.g., “Running on http://0.0.0.0:5000”), open your web browser and navigate to http://localhost:5000.

    You should see your Flask application’s homepage or the response from your /health endpoint (e.g., {"status": "ok"}).

  3. Check Database Connectivity: Attempt to interact with your application in a way that requires database access, such as registering a new user or logging in with existing credentials. If these operations are successful, it confirms that your Flask application is correctly connecting to the PostgreSQL database running in its own container.

    If you encounter issues, you can inspect the logs of your Flask application for database connection errors:

    docker compose logs web
  4. Inspect Running Containers: To confirm that both your web and db services are running as expected:

    docker ps

    You should see two entries, one for your Flask application (<project_name>-web-1) and one for PostgreSQL (<project_name>-db-1).

  5. Stop Containers: When you’re finished developing or testing, you can stop and remove the containers:

    docker compose down

    This command stops and removes the containers and their default network. However, by default, it preserves the db_data volume, so your database data remains intact for the next time you start the services.

    To stop and remove containers, networks, and the named volume (useful for a completely clean start, but will delete your database data):

    docker compose down -v

Production Hardening & Security Considerations

While our Docker setup provides a solid foundation, deploying to production requires additional hardening.

  • Secret Management: Never hardcode sensitive credentials (e.g., SECRET_KEY, DATABASE_URL with actual passwords) directly in docker-compose.yml for production. Instead, use a dedicated secret management solution. Options include:
    • Environment variables (still, but injected securely, e.g., via a CI/CD pipeline or orchestrator).
    • Cloud provider secret managers (AWS Secrets Manager, Azure Key Vault, Google Secret Manager).
    • Orchestrator-specific secrets (Kubernetes Secrets, Docker Swarm Secrets).
    • HashiCorp Vault.
  • Production WSGI Server: Replace flask run in your Dockerfile’s CMD with a robust, production-grade WSGI server like Gunicorn or uWSGI. These servers are designed for performance, stability, and handling concurrent requests, which flask run is not.
    • Example Gunicorn CMD: CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:create_app()"] (adjust app:create_app() based on your app’s entry point).
  • Resource Limits: In docker-compose.yml (or your orchestrator’s configuration), specify CPU and memory limits for your services under the deploy key. This prevents a single container from consuming all host resources, which is crucial for stability and cost control.
  • Logging Strategy: Configure your application to log to stdout and stderr. Docker’s logging drivers can then collect these logs, which can be centralized by a logging solution (e.g., ELK stack, Splunk, cloud-native logging services) for monitoring and security analysis.
  • Health Checks: Implement healthcheck configurations in docker-compose.yml for both web and db services. This allows Docker and orchestration systems to determine if a service is truly ready to serve traffic or if it has become unhealthy, enabling automated restarts or traffic rerouting.
  • Read-Only Filesystem: For enhanced security, consider making your container’s filesystem read-only (except for necessary writeable paths like /tmp). This prevents unauthorized writes, tampering, and the injection of malicious files.
  • Non-Root User: As implemented in the Dockerfile, always run your application inside the container as a non-root user. This is a critical security best practice to minimize the blast radius if the container is compromised.
  • Image Scanning: Integrate vulnerability scanning tools (e.g., Trivy, Snyk, Docker Scout) into your CI/CD pipeline to automatically check your Docker images for known vulnerabilities in base images and installed packages.
  • Network Security: In a cloud deployment, ensure appropriate network security groups or firewalls are configured to limit inbound and outbound traffic to only what is necessary, following the principle of least privilege.
  • Database Backup & Restore: Implement a robust strategy for regular database backups and a tested restore process. Docker volumes make data persistence easier, but they are not a backup solution.

Common Issues & Troubleshooting

  • Port Conflict:
    • Issue: Error starting userland proxy: listen tcp 0.0.0.0:5000: bind: address already in use.
    • Solution: Another process on your host machine is already using port 5000. Identify and stop that process, or change the host port mapping in docker-compose.yml (e.g., change "5000:5000" to "8000:5000" to map to host port 8000).
  • Database Connection Refused:
    • Issue: psycopg.OperationalError: connection to host "db" (xxx.xxx.xxx.xxx) refused.
    • Solution:
      • Verify the db service is running successfully: docker compose logs db. Look for PostgreSQL startup messages.
      • Ensure the DATABASE_URL in your docker-compose.yml and app/config.py uses db as the host and the correct credentials (user, password, mydatabase).
      • While depends_on helps, the Flask app might try to connect before PostgreSQL is fully initialized. For production, consider adding a startup script to the web service that waits for the database to be ready (e.g., using wait-for-it.sh or a simple Python loop).
  • Missing Python Dependencies in Container:
    • Issue: ModuleNotFoundError: No module named 'flask_bcrypt' or similar.
    • Solution: Ensure all required Python packages are listed in your requirements.txt file and that the pip install -r requirements.txt command in your Dockerfile runs without errors. After modifying requirements.txt or Dockerfile, always rebuild your images with docker compose up --build.
  • Volume Permission Errors (PostgreSQL):
    • Issue: PostgreSQL container cannot write to /var/lib/postgresql/data. This can manifest as startup failures for the db service.
    • Solution: This is more common on Linux hosts due to UID/GID mismatches between the host and the container. Docker Desktop usually handles this well. If it occurs, you might need to manually adjust permissions on the underlying Docker volume or specify a user in the db service definition (e.g., user: "1000:1000" if that’s the UID/GID of the postgres user in the container).

Summary & Next Steps

You have successfully achieved a major milestone: containerizing your secure Flask application and integrating it with a PostgreSQL database using Docker and Docker Compose. This crucial step provides a consistent, isolated, and portable environment, which is fundamental for modern secure development and deployment workflows.

In this chapter, you have:

  • Prepared your application dependencies for containerization.
  • Crafted a Dockerfile to build a lightweight and secure image for your Flask application, including running as a non-root user.
  • Written a docker-compose.yml file to orchestrate both your Flask app and PostgreSQL database, managing their network and persistent storage.
  • Updated your Flask application to securely fetch configuration from environment variables.
  • Verified that your entire containerized stack runs correctly locally.

Your application is now packaged and ready for deployment. In the next chapter, we will take this containerized application and deploy it to a cloud platform, focusing on secure deployment practices and further hardening in a production environment.

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.