Running databases in Docker is one of the most debated topics in infrastructure engineering. Critics argue that containers add unnecessary overhead for stateful workloads. Proponents point out that Docker provides reproducibility, isolation, and simplified deployment that bare-metal installations cannot match. The truth, as usual, is nuanced: Docker is an excellent choice for development, testing, and many production workloads, but it demands careful attention to persistence, performance tuning, and operational practices.

This guide provides production-tested configurations for four of the most popular databases, along with the operational knowledge you need to run them reliably in containers.

Data Persistence: The Foundation

Every database container discussion starts with persistence. Docker containers are ephemeral, and the writable layer is destroyed when a container is removed. For databases, this is catastrophic unless you are using named volumes or bind mounts.

Storage Method Performance Portability Use Case
Named volumes Excellent (managed by Docker) Docker-managed, not directly visible on host Production, most use cases
Bind mounts Excellent (direct filesystem) Host-path dependent When you need host filesystem access
tmpfs mounts Fastest (RAM-backed) Not persistent Testing, ephemeral caches
Volume drivers (NFS, EBS) Varies (network latency) Cross-host portability Multi-node clusters
Warning: Never use anonymous volumes or the container writable layer for database data. A docker compose down -v or docker rm -v will permanently destroy your data. Always use explicitly named volumes.

PostgreSQL in Docker

PostgreSQL is the most Docker-friendly relational database. Its configuration is straightforward, its startup is fast, and its official image is well-maintained.

Production-Ready Configuration

services:
  postgres:
    image: postgres:16-alpine
    container_name: postgres
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro
      - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
    environment:
      POSTGRES_USER: ${PG_USER:-postgres}
      POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD required}
      POSTGRES_DB: ${PG_DB:-appdb}
      PGDATA: /var/lib/postgresql/data/pgdata
    command: postgres -c config_file=/etc/postgresql/postgresql.conf
    ports:
      - "${PG_PORT:-5432}:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${PG_USER:-postgres} -d ${PG_DB:-appdb}"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4G
        reservations:
          cpus: "1.0"
          memory: 1G
    shm_size: '256mb'

volumes:
  pgdata:

The shm_size setting is critical for PostgreSQL. The default Docker shared memory allocation (64MB) is too small for any non-trivial workload and will cause errors with parallel queries and large sort operations.

Performance Tuning

# postgresql.conf - Tuned for 4GB RAM Docker container
# Memory
shared_buffers = 1GB              # 25% of available memory
effective_cache_size = 3GB        # 75% of available memory
work_mem = 16MB                   # Per-operation memory
maintenance_work_mem = 256MB      # For VACUUM, INDEX
huge_pages = try                  # Use if kernel supports

# WAL
wal_buffers = 64MB
max_wal_size = 2GB
min_wal_size = 512MB
checkpoint_completion_target = 0.9

# Query Planning
random_page_cost = 1.1            # For SSD storage
effective_io_concurrency = 200    # For SSD storage
default_statistics_target = 100

# Connections
max_connections = 200
superuser_reserved_connections = 3

# Logging
log_min_duration_statement = 250  # Log slow queries (ms)
log_checkpoints = on
log_connections = on
log_disconnections = on
log_lock_waits = on
log_temp_files = 0

Initialization Scripts

Files placed in /docker-entrypoint-initdb.d/ run automatically on first startup, in alphabetical order. They can be SQL files or shell scripts:

# initdb/01-extensions.sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS uuid-ossp;

# initdb/02-create-schemas.sql
CREATE SCHEMA IF NOT EXISTS app;
CREATE SCHEMA IF NOT EXISTS analytics;

# initdb/03-create-users.sh
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
  CREATE USER readonly WITH PASSWORD '${READONLY_PASSWORD}';
  GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO readonly;
  GRANT USAGE ON SCHEMA public TO readonly;
  ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly;
EOSQL

MySQL / MariaDB in Docker

Production Configuration

services:
  mysql:
    image: mysql:8.4
    container_name: mysql
    restart: unless-stopped
    volumes:
      - mysqldata:/var/lib/mysql
      - ./mysql.cnf:/etc/mysql/conf.d/custom.cnf:ro
      - ./initdb:/docker-entrypoint-initdb.d:ro
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Required}
      MYSQL_DATABASE: ${MYSQL_DB:-appdb}
      MYSQL_USER: ${MYSQL_USER:-app}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Required}
    ports:
      - "${MYSQL_PORT:-3306}:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4G
    command: >
      --innodb-buffer-pool-size=2G
      --innodb-log-file-size=256M
      --innodb-flush-method=O_DIRECT
      --max-connections=200
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_unicode_ci

volumes:
  mysqldata:

MySQL Performance Tuning

# mysql.cnf - Tuned for 4GB RAM container
[mysqld]
# InnoDB Settings
innodb_buffer_pool_size = 2G       # 50-70% of available RAM
innodb_buffer_pool_instances = 4   # One per GB of pool
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2 # Balance durability/performance
innodb_flush_method = O_DIRECT
innodb_file_per_table = ON
innodb_io_capacity = 2000          # For SSD
innodb_io_capacity_max = 4000

# Query Cache (disabled in MySQL 8+, use ProxySQL)
# query_cache_type = 0

# Connection
max_connections = 200
thread_cache_size = 16
table_open_cache = 4096
table_definition_cache = 2048

# Binary Log (for replication)
server-id = 1
log-bin = mysql-bin
binlog_format = ROW
expire_logs_days = 7
max_binlog_size = 100M

# Slow Query Log
slow_query_log = ON
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time = 0.5

# Character Set
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

Redis in Docker

Production Configuration

services:
  redis:
    image: redis:7-alpine
    container_name: redis
    restart: unless-stopped
    volumes:
      - redisdata:/data
      - ./redis.conf:/usr/local/etc/redis/redis.conf:ro
    command: redis-server /usr/local/etc/redis/redis.conf
    ports:
      - "${REDIS_PORT:-6379}:6379"
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
      interval: 5s
      timeout: 3s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 2G
    sysctls:
      - net.core.somaxconn=511

volumes:
  redisdata:
# redis.conf
# Persistence
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# Memory
maxmemory 1536mb
maxmemory-policy allkeys-lru

# Security
requirepass ${REDIS_PASSWORD}
rename-command FLUSHDB ""
rename-command FLUSHALL ""
rename-command DEBUG ""

# Performance
tcp-backlog 511
timeout 300
tcp-keepalive 60
hz 10

# Logging
loglevel notice
Tip: The sysctls setting for net.core.somaxconn is important for Redis under high connection load. Without it, Redis will warn about the TCP backlog being silently truncated. Note that sysctls requires the container to run with appropriate capabilities or the host to allow it.

MongoDB in Docker

Production Configuration

services:
  mongodb:
    image: mongo:7
    container_name: mongodb
    restart: unless-stopped
    volumes:
      - mongodata:/data/db
      - mongoconfigdb:/data/configdb
      - ./mongod.conf:/etc/mongod.conf:ro
      - ./initdb:/docker-entrypoint-initdb.d:ro
    command: mongod --config /etc/mongod.conf
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?Required}
      MONGO_INITDB_DATABASE: ${MONGO_DB:-appdb}
    ports:
      - "${MONGO_PORT:-27017}:27017"
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4G

volumes:
  mongodata:
  mongoconfigdb:
# mongod.conf
storage:
  dbPath: /data/db
  journal:
    enabled: true
  wiredTiger:
    engineConfig:
      cacheSizeGB: 1.5    # ~40% of container memory
      journalCompressor: snappy
    collectionConfig:
      blockCompressor: snappy
    indexConfig:
      prefixCompression: true

net:
  port: 27017
  bindIp: 0.0.0.0
  maxIncomingConnections: 500

operationProfiling:
  mode: slowOp
  slowOpThresholdMs: 100

security:
  authorization: enabled

Backup Strategies per Database

Each database has specific backup requirements. Raw volume copies are not safe for running databases because they may capture an inconsistent state.

# PostgreSQL: pg_dump for logical backups
docker exec postgres pg_dump -U postgres -Fc appdb > backup_$(date +%Y%m%d).dump
# Custom format (-Fc) supports parallel restore and selective table restore

# MySQL: mysqldump with single-transaction
docker exec mysql mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" \
  --single-transaction --routines --triggers --events appdb | \
  gzip > backup_$(date +%Y%m%d).sql.gz

# Redis: trigger RDB snapshot then copy
docker exec redis redis-cli BGSAVE
sleep 2
docker cp redis:/data/dump.rdb ./backup_redis_$(date +%Y%m%d).rdb

# MongoDB: mongodump with archive
docker exec mongodb mongodump --username admin --password "$MONGO_PASSWORD" \
  --authenticationDatabase admin --archive --gzip \
  --db appdb > backup_mongo_$(date +%Y%m%d).gz

Connection Pooling

Database connections are expensive resources. In a containerized environment where multiple application instances may connect to the same database, connection pooling is essential.

PgBouncer for PostgreSQL

services:
  pgbouncer:
    image: bitnami/pgbouncer:latest
    restart: unless-stopped
    ports:
      - "6432:6432"
    environment:
      POSTGRESQL_HOST: postgres
      POSTGRESQL_PORT: 5432
      POSTGRESQL_USERNAME: postgres
      POSTGRESQL_PASSWORD: ${PG_PASSWORD}
      POSTGRESQL_DATABASE: appdb
      PGBOUNCER_POOL_MODE: transaction
      PGBOUNCER_MAX_CLIENT_CONN: 500
      PGBOUNCER_DEFAULT_POOL_SIZE: 25
      PGBOUNCER_MIN_POOL_SIZE: 5
      PGBOUNCER_RESERVE_POOL_SIZE: 5
    depends_on:
      postgres:
        condition: service_healthy

ProxySQL for MySQL

services:
  proxysql:
    image: proxysql/proxysql:latest
    restart: unless-stopped
    ports:
      - "6033:6033"    # MySQL protocol
      - "6032:6032"    # Admin interface
    volumes:
      - ./proxysql.cnf:/etc/proxysql.cnf:ro
      - proxysqldata:/var/lib/proxysql
    depends_on:
      mysql:
        condition: service_healthy

volumes:
  proxysqldata:

Replication Patterns

For read-heavy workloads, you can set up read replicas entirely within Docker Compose:

services:
  postgres-primary:
    image: bitnami/postgresql:16
    environment:
      POSTGRESQL_REPLICATION_MODE: master
      POSTGRESQL_REPLICATION_USER: replicator
      POSTGRESQL_REPLICATION_PASSWORD: ${REPL_PASSWORD}
      POSTGRESQL_USERNAME: postgres
      POSTGRESQL_PASSWORD: ${PG_PASSWORD}
      POSTGRESQL_DATABASE: appdb
    volumes:
      - pg_primary:/bitnami/postgresql

  postgres-replica:
    image: bitnami/postgresql:16
    depends_on:
      - postgres-primary
    environment:
      POSTGRESQL_REPLICATION_MODE: slave
      POSTGRESQL_REPLICATION_USER: replicator
      POSTGRESQL_REPLICATION_PASSWORD: ${REPL_PASSWORD}
      POSTGRESQL_MASTER_HOST: postgres-primary
      POSTGRESQL_MASTER_PORT_NUMBER: 5432
      POSTGRESQL_PASSWORD: ${PG_PASSWORD}
    volumes:
      - pg_replica:/bitnami/postgresql

volumes:
  pg_primary:
  pg_replica:

Monitoring Database Containers

Monitoring is not optional for production databases. Here is a monitoring stack using exporters:

services:
  postgres-exporter:
    image: quay.io/prometheuscommunity/postgres-exporter
    environment:
      DATA_SOURCE_NAME: "postgresql://postgres:${PG_PASSWORD}@postgres:5432/appdb?sslmode=disable"
    ports:
      - "9187:9187"
    depends_on:
      postgres:
        condition: service_healthy

  mysql-exporter:
    image: prom/mysqld-exporter
    environment:
      DATA_SOURCE_NAME: "root:${MYSQL_ROOT_PASSWORD}@(mysql:3306)/"
    ports:
      - "9104:9104"

  redis-exporter:
    image: oliver006/redis_exporter
    environment:
      REDIS_ADDR: redis://redis:6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    ports:
      - "9121:9121"

  mongodb-exporter:
    image: percona/mongodb_exporter:0.40
    command:
      - "--mongodb.uri=mongodb://admin:${MONGO_PASSWORD}@mongodb:27017"
      - "--compatible-mode"
    ports:
      - "9216:9216"

When NOT to Use Docker for Databases

Docker is not always the right answer for databases. Consider bare-metal or VM-based deployments when:

  • Extreme I/O performance is required: Despite improvements, Docker adds a thin storage driver layer. For databases pushing millions of IOPS, the overhead matters.
  • Large memory requirements: Databases larger than 64GB RAM benefit from direct NUMA-aware memory allocation that containers do not easily provide.
  • Complex replication topologies: Multi-master, geo-distributed replication is harder to manage across container boundaries.
  • Regulatory requirements: Some compliance frameworks require dedicated hardware or specific isolation guarantees that containers alone do not satisfy.
  • Your team lacks container expertise: A well-managed bare-metal PostgreSQL beats a poorly managed containerized one every time.

Rule of thumb: If your database fits within the resources of a single server (as most do), Docker is fine for production. If you need a multi-node cluster, evaluate managed services or bare-metal deployments.

Resource Limits: Preventing Runaway Containers

Always set memory limits for database containers. Without limits, a memory leak or unexpected query load can consume all host memory and trigger the OOM killer on unrelated containers.

services:
  postgres:
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4G
        reservations:
          cpus: "1.0"
          memory: 2G

Match your database configuration to the container limits. A PostgreSQL container with a 4GB memory limit should have shared_buffers set to roughly 1GB (25% of limit), not 25% of the host RAM.

Tools like usulnet provide real-time visibility into container resource consumption, making it straightforward to identify when a database container is approaching its memory limit before the OOM killer intervenes. This kind of proactive monitoring is essential for any production database deployment running in containers.