Running Databases in Docker: PostgreSQL, MySQL, Redis and MongoDB
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 |
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
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.