DEVOPS 10 min

Docker for Databases: Production Deployment of MySQL with Docker Compose

Enterprise guide to running production MySQL workloads in containers: persistent storage volumes, mitigating the Docker iptables firewall bypass, secure Docker Secrets injection, and non-blocking hot backups.

Written by Samir El Azem Crovetto • Senior Backend Engineer & DevSecOps

The Challenge of Stateful Workloads in Containerized Infrastructure

Docker and container orchestration were primarily popularized around stateless applications: web APIs, microservices, and asynchronous event consumers that can be terminated, rescheduled, or horizontally scaled across compute nodes without risk of data loss.

However, when this abstraction is applied to relational database engines (stateful workloads) such as MySQL, MariaDB, or PostgreSQL, operational stakes increase dramatically. Architectural oversights in persistent volume configuration, user permissions, or network port exposure can lead to catastrophic failure: silent data corruption caused by incomplete disk synchronization (fsync), irreversible data destruction during container recreation, or unintentional database exposure to public Internet port scanners.

Operating relational databases inside containers is not only feasible in production, but highly advantageous for establishing reproducible, isolated, and automated infrastructure—provided that engineering teams rigorously enforce storage persistence, network perimeter isolation, resource governance, and secret hygiene.

This technical guide outlines an enterprise architecture for running production MySQL workloads with Docker Compose: from named storage volumes and mitigating the Docker iptables trap to container healthchecks, runtime secret injection, and non-blocking hot backup automation.


Secure Network Topology & Persistence Architecture in Docker Compose

The following diagram illustrates the network isolation perimeter and persistent storage workflow for production database deployments:

flowchart TD
    subgraph Public_Traffic ["External Traffic / Internet"]
        U[Web Users / Clients] -->|HTTPS 443| RP[Reverse Proxy / Nginx / Caddy]
    end

    subgraph Docker_Bridge_Networks ["Internal Docker Networks"]
        subgraph Frontend_Net ["frontend_network (Isolated)"]
            RP -->|Proxy Pass| APP[Backend API / Spring Boot / Node.js]
        end

        subgraph Backend_Net ["backend_network (Completely Private)"]
            APP -->|Internal TCP 3306| DB[(MySQL 8.0 / 8.4 LTS<br/>UID 999 non-root)]
            SEC[Docker Secrets<br/>/run/secrets/db_password] -.->|In-Memory Mount| DB
        end
    end

    subgraph Host_Storage ["Persistent Host Storage"]
        DB -->|Direct I/O fsync| VOL[(Docker Named Volume<br/>mysql_production_data)]
        DB -->|Binlog Rotation| BLOG[(Volume: mysql_binlogs)]
    end

    classDef public fill:#181825,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4;
    classDef network fill:#1e1e2e,stroke:#6366f1,stroke-width:2px,color:#cdd6f4;
    classDef storage fill:#181825,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4;
    class Public_Traffic public;
    class Docker_Bridge_Networks,Frontend_Net,Backend_Net network;
    class Host_Storage storage;

1. Storage Architecture: Named Volumes vs. Host Bind Mounts

The primary design decision centers on how and where MySQL InnoDB tables and logs (/var/lib/mysql) persist on physical disk.

Docker provides two primary storage mapping strategies:

  1. Host Bind Mounts (/srv/mysql:/var/lib/mysql): Mounts an arbitrary host directory. While intuitive, bind mounts introduce operational friction in production: they offload permission management (UID/GID) to the host operating system, risk permission desynchronization, and incur I/O latency penalties across virtualized filesystems.
  2. Named Volumes (named volumes): Managed directly by the Docker daemon under /var/lib/docker/volumes/. They deliver superior I/O throughput, maintain native internal container file permissions (mysql with UID 999), and isolate database data from accidental host-level manipulation.

InnoDB Performance & Durability Tuning

When executing MySQL inside containerized storage, runtime configurations in /etc/mysql/conf.d/custom.cnf must balance ACID transaction durability against disk write overhead:

  • innodbflushmethod = O_DIRECT: Prevents double buffering by directing MySQL to bypass the operating system filesystem cache, eliminating duplicate memory allocations.
  • innodbflushlogattrx_commit = 1: Enforces strict ACID durability by ensuring the redo log is flushed to physical disk (fsync) upon every transaction commit.
  • innodbbufferpool_size: Should be allocated approximately 60% to 70% of the total memory limit assigned to the container via Docker Compose cgroups.

2. Perimeter Isolation and the Docker iptables Trap

One of the most widespread security misconfigurations in containerized environments is the accidental exposure of private databases to the public Internet.

How Docker Bypasses Host Firewalls (UFW / iptables)

On Linux hosts (such as Ubuntu or Debian), system administrators configure firewalls like UFW to reject unauthorized incoming traffic by default. However, when a service is declared with:

ports:
  - "3306:3306"

The Docker daemon inserts rules directly into the PREROUTING table and DOCKER chain in iptables before UFW evaluates incoming packets. Consequently, port 3306 becomes accessible to external traffic, completely bypassing local host firewall policies.

Architectural Mitigation Strategies:

For production environments, two secure patterns exist:

  1. Zero Port Publishing (Recommended Standard): The database and application microservices share an internal bridge network (backend_network). Microservices connect using the internal Docker DNS name (mysql-db:3306). No ports are exposed or published to the host network interface.
  2. Strict Loopback Binding: If administrative access or local debugging is necessary, bind explicitly to the localhost interface:
   ports:
     - "127.0.0.1:3306:3306"

This ensures connections originate strictly from the local host or via an encrypted SSH tunnel, as outlined in our guide on SSH Server Hardening and Bastion Architecture.


3. Credential Security with Docker Secrets

Hardcoding plaintext credentials inside docker-compose.yml (e.g., MYSQLROOTPASSWORD: secret123) constitutes an immediate security vulnerability: secrets leak into version control history, audit logs, and process inspect manifests.

The enterprise pattern in Docker Compose leverages Docker Secrets or in-memory mounted secret files:

secrets:
  db_root_password:
    file: ./secrets/db_root_password.txt
  db_app_password:
    file: ./secrets/db_app_password.txt

Official MySQL container images natively support the _FILE environment variable suffix:

environment:
  MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
  MYSQL_PASSWORD_FILE: /run/secrets/db_app_password

The container process reads the password directly from virtual memory mounts (/run/secrets/), ensuring the sensitive credential never leaks into inspectable container environment variables.


4. Hardened Production Manifest: docker-compose.yml

Below is an enterprise production Docker Compose file featuring segregated networks, container healthchecks, cgroup resource constraints, and secure secret injection:

# docker-compose.yml - Hardened Production MySQL 8.0 / 8.4 LTS
version: '3.8'

services:
  mysql-db:
    image: mysql:8.0.36
    container_name: production_mysql
    restart: unless-stopped
    # Execute as unprivileged system user inside container
    user: "999:999"
    
    # Inject credentials securely via file mounts
    secrets:
      - db_root_password
      - db_app_password
      
    environment:
      MYSQL_DATABASE: corporate_prod
      MYSQL_USER: app_service
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
      MYSQL_PASSWORD_FILE: /run/secrets/db_app_password
      
    # Hardware resource constraints (cgroups v2)
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2048M
        reservations:
          cpus: '0.5'
          memory: 1024M

    # Native database healthcheck for coordinated service boot
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u $MYSQL_USER --password=$(cat /run/secrets/db_app_password) --silent"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

    # Restrict published port strictly to localhost (for SSH jump tunnels)
    ports:
      - "127.0.0.1:3306:3306"

    # Named volumes and configuration mounts
    volumes:
      - mysql_data:/var/lib/mysql
      - mysql_logs:/var/log/mysql
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
      - ./config/custom.cnf:/etc/mysql/conf.d/custom.cnf:ro

    # Private internal network
    networks:
      - backend_network

  api-service:
    image: registry.enterprise.com/services/core-api:1.2.0
    container_name: core_api_service
    restart: unless-stopped
    depends_on:
      mysql-db:
        condition: service_healthy
    networks:
      - backend_network
      - frontend_network
    environment:
      DB_HOST: mysql-db
      DB_PORT: 3306
      DB_NAME: corporate_prod
      DB_USER: app_service

volumes:
  mysql_data:
    name: prod_mysql_data
  mysql_logs:
    name: prod_mysql_logs

networks:
  backend_network:
    name: prod_backend_network
    internal: true # Blocks all incoming and outgoing Internet traffic
  frontend_network:
    name: prod_frontend_network

secrets:
  db_root_password:
    file: ./secrets/db_root_password.txt
  db_app_password:
    file: ./secrets/db_app_password.txt

5. Idempotent Initialization with docker-entrypoint-initdb.d

MySQL container images automatically execute .sh, .sql, or .sql.gz scripts located inside /docker-entrypoint-initdb.d/ strictly during initial volume provisioning.

To ensure automated deployment pipelines are resilient, schema initialization scripts must be authored idempotently:

-- init-scripts/01-init-schema.sql
CREATE TABLE IF NOT EXISTS security_audit_logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_type VARCHAR(64) NOT NULL,
    actor_id VARCHAR(128) NOT NULL,
    ip_address VARCHAR(45) NOT NULL,
    metadata JSON NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_actor_created (actor_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

6. Consistent Hot Backups and Disaster Recovery

Deploying databases without automated backup verification invites operational disaster.

In transactional databases powered by InnoDB, copying raw data files from /var/lib/mysql while the daemon is actively executing risks silent data corruption due to unflushed buffer pools.

The standard operational procedure involves executing non-blocking logical dumps using mysqldump with --single-transaction, which establishes a consistent snapshot without locking application write transactions:

Production Automated Backup Script (backup-mysql.sh)

#!/usr/bin/env bash
set -euo pipefail

# Configuration parameters
CONTAINER_NAME="production_mysql"
DATABASE_NAME="corporate_prod"
BACKUP_DIR="/opt/backups/mysql"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="${BACKUP_DIR}/backup_${DATABASE_NAME}_${TIMESTAMP}.sql.gz"
SECRET_FILE="/opt/app/secrets/db_root_password.txt"
RETENTION_DAYS=14

mkdir -p "${BACKUP_DIR}"

# Extract password from secret file
MYSQL_PWD=$(cat "${SECRET_FILE}")

echo "[$(date)] Initiating consistent hot backup for ${DATABASE_NAME}..."

# Execute mysqldump inside container and compress in-flight
docker exec -e MYSQL_PWD="${MYSQL_PWD}" "${CONTAINER_NAME}" \
  mysqldump \
    -u root \
    --single-transaction \
    --quick \
    --skip-lock-tables \
    --routines \
    --triggers \
    "${DATABASE_NAME}" | gzip -9 > "${BACKUP_FILE}"

# Set strict file permissions
chmod 600 "${BACKUP_FILE}"

echo "[$(date)] Backup completed successfully: ${BACKUP_FILE}"

# Purge backups exceeding retention window
find "${BACKUP_DIR}" -type f -name "backup_*.sql.gz" -mtime +${RETENTION_DAYS} -delete
echo "[$(date)] Retention policy enforced (${RETENTION_DAYS} days)."

Verified Disaster Recovery Procedure:

To test disaster recovery inside an isolated staging environment:

# Restore compressed backup archive into database container
gunzip < /opt/backups/mysql/backup_corporate_prod_20261027_040000.sql.gz | \
  docker exec -i -e MYSQL_PWD=$(cat ./secrets/db_root_password.txt) production_mysql \
  mysql -u root corporate_prod

Conclusion

Deploying stateful relational databases via Docker Compose provides a robust foundation for modern cloud architectures when backed by disciplined engineering controls: complete network isolation without accidental public port leaks, decoupled persistence via named volumes, secretless environment manifests, and automated transactional backups.

This infrastructure-as-code discipline aligns directly with Linux Server Hardening, foundational Docker & Container Architecture, and continuous DevSecOps Verification.

If your organization needs to design persistent container storage architectures, audit Docker network boundaries, or establish automated disaster recovery procedures for mission-critical databases, explore my Cybersecurity Consulting Services or reach out directly on my About Me profile.