Docker 78 š³ Containerizing Database Engines: PostgreSQL, MySQL, Redis (Persisting Data & Performance)
A database container is not like a web server container. The web server is statelessākill it, restart it, and it serves the same content. The database holds state, and the entire value of the containerization exercise depends on that state surviving. Docker’s default filesystem is ephemeral: when the container is removed, everything written inside it is gone . The first decision in containerizing any database is therefore not the image tag or the port mapping. It is the persistence strategy. The second decision is the performance configuration, because the default settings of every official database image are tuned for development, not for the workload the container will actually face .
The three databases in this chapter represent three different persistence and performance profiles. PostgreSQL needs a named volume at /var/lib/postgresql/data, memory parameters tuned to the container’s memory limit, and an awareness of the container’s default /dev/shm size . MySQL needs a named volume at /var/lib/mysql, a buffer pool sized to the container’s memory, and a deliberate decision about the durability-versus-speed trade-off that the default configuration makes in the wrong direction . Redis is the outlier: it is often used as a pure cache with no persistence at all, and the correct configuration for that use case is to disable persistence and set an eviction policy . When Redis is used as a data store, the persistence choice is between RDB snapshots and AOF logs .
Key point: Every database container needs a named volume for its data directory, because the container filesystem is ephemeral. PostgreSQL and MySQL need their memory parameters sized to the container’s memory limit, not the host’s. Redis needs a decision: if it is a cache, disable persistence and set maxmemory-policy; if it is a data store, enable AOF with everysec.
Why database containerization requires different rules
The ephemeral filesystem problem. A container’s writable layer is deleted when the container is removed. A MySQL container without a volume loses every table, every row, and every index the moment docker rm runs . The fix is a volume, but the choice of volume type matters. A bind mount ties the data to a specific host directory and a specific UID/GID; if the container’s user does not match the host directory’s owner, the database fails to start . A named volume is managed by Docker, and Docker sets the permissions correctly for the image’s user .
The memory sizing problem. PostgreSQL’s default shared_buffers is 128 MB. MySQL’s default innodb_buffer_pool_size is 128 MB . These defaults are designed for a developer’s laptop, not for a container with a 4 GB or 8 GB memory limit. A buffer pool that is too small causes the database to thrash: it reads pages from disk that it should have kept in memory, and every query becomes an I/O operation. The correct sizing is a fraction of the container’s memory limit, not the host’s total memory .
The container-specific parameter problem. PostgreSQL’s official image inherits the container’s /dev/shm size, which is 64 MB by default on Linux. For a database that uses shared memory for parallel query workers, 64 MB is too small, and the fix is --shm-size on the docker run command or shm_size in Compose . MySQL’s official image runs as UID 999, and a bind mount owned by a different UID prevents the server from starting . These are not database parameters; they are container parameters that affect the database.
The durability-versus-speed problem. MySQL’s default innodb_flush_log_at_trx_commit=1 fsyncs on every commit, which is the safe setting. For a migration target or a development database, 2 fsyncs once per second and is significantly faster, at the cost of losing up to one second of committed transactions in a crash . The setting is not wrongāit is a trade-off that must be made deliberately. The same trade-off exists for Redis: RDB snapshots lose the data written since the last snapshot, while AOF with everysec loses at most one second .
a. PostgreSQL: memory tuning and the /dev/shm constraint
The PostgreSQL container needs three things: a named volume for /var/lib/postgresql/data, memory parameters sized to the container’s memory limit, and a larger /dev/shm if the workload uses parallel queries.
# The PostgreSQL service in docker-compose.yml
services:
postgres:
# Pin the major version; latest is not a production tag
image: postgres:16
container_name: postgres
# The environment variables create the superuser and the initial database
# POSTGRES_PASSWORD is required; the container will not start without it
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: app_user
POSTGRES_DB: app_db
# The named volume persists the data directory
# Docker manages the volume and its permissions
volumes:
- pgdata:/var/lib/postgresql/data
# The port mapping exposes PostgreSQL on the host
ports:
- "5432:5432"
# The shared memory size for parallel query workers
# The default 64 MB is too small for parallel queries
shm_size: 256mb
# The memory limit for the container
# PostgreSQL's shared_buffers should be a fraction of this
deploy:
resources:
limits:
memory: 4g
# The restart policy keeps the database running after a crash
restart: unless-stopped
# The named volume declaration
volumes:
pgdata:
The shm_size is the parameter that is specific to containers. PostgreSQL uses the /dev/shm filesystem for dynamic shared memory, which is used by parallel query workers and by the pg_stat_statements extension. The container runtime gives /dev/shm a default of 64 MB, which is enough for a small workload but not for a database that runs parallel queries . The shm_size: 256mb line increases it.
The memory parameters are set through the command directive, which passes them to the postgres process:
# The command passes PostgreSQL configuration parameters
# The values are sized to the 4 GB container limit
command: >
postgres
-c shared_buffers=1GB
-c effective_cache_size=3GB
-c work_mem=16MB
-c maintenance_work_mem=256MB
-c max_connections=100
The shared_buffers is 25% of the container’s memory limit (1 GB of 4 GB). The effective_cache_size is a planner hint, not an allocation; it tells PostgreSQL how much memory the OS page cache and the shared buffers together are likely to provide, and 75% of the container limit is the common heuristic . The work_mem is per sort or hash operation, and it should be small enough that many concurrent operations do not exhaust memory; 16 MB is a reasonable starting point. The maintenance_work_mem is for vacuum and index creation, and it can be larger because those operations are less concurrent.
b. MySQL: buffer pool sizing and the durability trade-off
MySQL’s container has the same persistence requirement, but the performance configuration is dominated by one parameter: innodb_buffer_pool_size. The default of 128 MB is the single largest cause of poor MySQL performance in a container .
services:
mysql:
# Pin the version; the latest tag is not reproducible
image: mysql:8.0.36
container_name: mysql
# The root password is required
# The MYSQL_DATABASE creates the initial database
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: app_db
# The named volume persists the data directory
# The official image runs as UID 999; the named volume handles the permissions
volumes:
- mysqldata:/var/lib/mysql
# The custom configuration file is mounted read-only
- ./my.cnf:/etc/mysql/conf.d/tuned.cnf:ro
ports:
- "3306:3306"
# The memory limit for the container
# The buffer pool should be 50-70% of this limit
deploy:
resources:
limits:
memory: 4g
# Disable swap to prevent MySQL from being swapped out
# memory-swap equal to memory means no swap
reservations:
memory: 4g
# The restart policy
restart: unless-stopped
volumes:
mysqldata:
The my.cnf file is where the performance parameters live:
# my.cnf for a 4 GB container
[mysqld]
# The buffer pool is the single most important parameter
# 2 GB is 50% of the 4 GB container limit
innodb_buffer_pool_size = 2G
# The number of buffer pool instances
# More instances reduce contention on large pools
innodb_buffer_pool_instances = 4
# The redo log capacity
# The default 96 MB is too small for write-heavy workloads
# 512 MB gives the writer room to breathe
innodb_redo_log_capacity = 512M
# The flush method skips the OS page cache
# InnoDB's buffer pool already caches pages
innodb_flush_method = O_DIRECT
# The I/O capacity for the page flusher
# The defaults assume spinning disks; NVMe needs higher values
innodb_io_capacity = 5000
innodb_io_capacity_max = 20000
# The durability setting
# 1 = fsync per commit (safe, slow)
# 2 = fsync per second (fast, loses up to 1s on crash)
# 1 is the production default; 2 is for migration targets
innodb_flush_log_at_trx_commit = 1
The innodb_flush_log_at_trx_commit is the parameter that requires a deliberate decision. The value 1 is the ACID-compliant setting: every committed transaction is durable. The value 2 fsyncs once per second, which is faster but loses up to one second of committed transactions if the container crashes . The right choice depends on the database’s role. A production application database should use 1. A migration target or a development database can use 2, because the data is reproducible and the speed gain is significant.
The user: "999:999" line is the fix for the UID/GID permission problem. The official MySQL image runs as UID 999, and a bind mount owned by a different UID will fail. The named volume avoids this problem because Docker sets the permissions, but if a bind mount is used, the host directory must be chown 999:999 .
c. Redis: cache mode versus data-store mode
Redis is the database where the persistence decision is most often “none.” A Redis used as a cache does not need persistence, and enabling it adds I/O overhead for no benefit. A Redis used as a data store needs persistence, and the choice is between RDB snapshots and AOF logs.
# Redis in cache mode
services:
redis-cache:
image: redis:7-alpine
container_name: redis-cache
# The command sets the memory limit and the eviction policy
# maxmemory caps the memory usage
# allkeys-lru evicts the least-recently-used keys when full
command: >
redis-server
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--save ""
--appendonly no
ports:
- "6379:6379"
restart: unless-stopped
The --save "" disables RDB snapshots, and --appendonly no disables AOF. The --maxmemory 512mb caps the memory, and --maxmemory-policy allkeys-lru evicts keys when the limit is reached. The default noeviction policy returns an error when the memory is full, which is the wrong behavior for a cache . The allkeys-lru policy evicts the least-recently-used keys and keeps the cache operational.
A Redis used as a data store needs persistence:
# Redis in data-store mode
services:
redis-store:
image: redis:7-alpine
container_name: redis-store
# The AOF persistence is enabled
# everysec fsyncs once per second
# This is the recommended balance between durability and speed
command: >
redis-server
--appendonly yes
--appendfsync everysec
--maxmemory 2gb
--maxmemory-policy noeviction
volumes:
# The named volume persists the AOF and RDB files
- redisdata:/data
ports:
- "6379:6379"
restart: unless-stopped
volumes:
redisdata:
The --appendonly yes enables the AOF log, and --appendfsync everysec is the recommended sync policy. The alternatives are always, which fsyncs on every write and is much slower, and no, which lets the OS decide and is the fastest but least durable . The --maxmemory-policy noeviction is correct for a data store: if the memory is full, the application should know, not have its data silently evicted.
Complete Example Session
The following session traces a Docker Compose deployment of all three databases, the persistence verification, and the performance configuration check.
# ============================================
# PART 1: THE COMPOSE FILE
# ============================================
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${PG_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
shm_size: 256mb
deploy:
resources:
limits:
memory: 4g
command: >
postgres
-c shared_buffers=1GB
-c effective_cache_size=3GB
restart: unless-stopped
mysql:
image: mysql:8.0.36
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysqldata:/var/lib/mysql
- ./my.cnf:/etc/mysql/conf.d/tuned.cnf:ro
deploy:
resources:
limits:
memory: 4g
restart: unless-stopped
redis:
image: redis:7-alpine
command: >
redis-server
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--save ""
--appendonly no
restart: unless-stopped
volumes:
pgdata:
mysqldata:
# ============================================
# PART 2: START AND VERIFY PERSISTENCE
# ============================================
# Start the stack
docker compose up -d
# Write data to PostgreSQL
docker compose exec postgres psql -U postgres -c "CREATE TABLE t (id int); INSERT INTO t VALUES (1);"
# Stop and remove the containers
docker compose down
# Start the stack again
docker compose up -d
# The table and the row are still there
docker compose exec postgres psql -U postgres -c "SELECT * FROM t;"
# Output: 1
# ============================================
# PART 3: VERIFY THE CONFIGURATION
# ============================================
# Check the PostgreSQL buffer pool
docker compose exec postgres psql -U postgres -c "SHOW shared_buffers;"
# Output: 1GB
# Check the MySQL buffer pool
docker compose exec mysql mysql -uroot -p${MYSQL_PASSWORD} -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
# Output: 2147483648 (2 GB)
# Check the Redis memory limit
docker compose exec redis redis-cli CONFIG GET maxmemory
# Output: maxmemory 536870912 (512 MB)
The three parts cover the Compose file with the three database services, the persistence verification that proves the data survives a container removal, and the configuration check that proves the memory parameters are applied. What the session demonstrates is that the persistence is the named volume, the performance is the parameter, and the two are independent decisions.
Quick Reference
Persistence Directories
| Database | Container Path | Volume Type |
|---|---|---|
| PostgreSQL | /var/lib/postgresql/data | Named volume |
| MySQL | /var/lib/mysql | Named volume |
| Redis | /data | Named volume |
Key Performance Parameters
| Database | Parameter | Default | Recommended |
|---|---|---|---|
| PostgreSQL | shared_buffers | 128 MB | 25% of container memory |
| PostgreSQL | effective_cache_size | 4 GB | 75% of container memory |
| MySQL | innodb_buffer_pool_size | 128 MB | 50-70% of container memory |
| Redis | maxmemory | 0 (unlimited) | Set to a fraction of container memory |
Container-Specific Settings
| Setting | Database | Purpose |
|---|---|---|
shm_size | PostgreSQL | Parallel query workers |
user: "999:999" | MySQL | Bind mount permissions |
memory-swap | MySQL | Disable swap |
--network=host | MySQL | Remove NAT overhead |
Persistence Modes
| Mode | Redis | Trade-off |
|---|---|---|
| Cache | save "", appendonly no | No persistence, fastest |
| RDB | save 900 1 | Snapshot, loses recent writes |
| AOF | appendonly yes, appendfsync everysec | Log, loses ā¤1s |
Best Practices
ā Do This:
# Use a named volume for the data directory
volumes:
- pgdata:/var/lib/postgresql/data
# Size the buffer pool to the container limit
command: postgres -c shared_buffers=1GB
# Disable persistence for a cache
command: redis-server --save "" --appendonly no
ā Don’t Do This:
# Use a bind mount without fixing permissions
volumes:
- /host/data:/var/lib/mysql # permission denied
# Leave the buffer pool at the default
# 128 MB causes thrashing on any real workload
# Use noeviction for a cache
command: redis-server --maxmemory 512mb # returns OOM errors
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Data lost after docker rm | No volume | Named volume |
| MySQL fails to start | Bind mount permission mismatch | user: "999:999" or named volume |
| PostgreSQL parallel query fails | /dev/shm too small | shm_size: 256mb |
| Redis returns OOM errors | noeviction policy | allkeys-lru for cache |
| Slow queries | Buffer pool too small | Size to container memory |
Real-World Examples
1. PostgreSQL named volume
volumes:
- pgdata:/var/lib/postgresql/data
2. PostgreSQL shared memory
shm_size: 256mb
3. MySQL named volume
volumes:
- mysqldata:/var/lib/mysql
4. MySQL buffer pool
innodb_buffer_pool_size = 2G
5. MySQL durability trade-off
innodb_flush_log_at_trx_commit = 2
6. Redis cache mode
command: redis-server --save "" --appendonly no --maxmemory 512mb --maxmemory-policy allkeys-lru
7. Redis AOF persistence
command: redis-server --appendonly yes --appendfsync everysec
8. Verify persistence
docker compose down && docker compose up -d
9. Check PostgreSQL config
docker compose exec postgres psql -U postgres -c "SHOW shared_buffers;"
10. Check MySQL config
docker compose exec mysql mysql -uroot -p -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
Visual
Persistence Strategy
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Container filesystem (ephemeral) ā
ā deleted on docker rm ā
ā ā ā
ā ā¼ ā
ā Named volume (persistent) ā
ā managed by Docker, survives removal ā
ā ā ā
ā ā¼ ā
ā Data directory mounted into container ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Memory Sizing
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Container memory limit: 4 GB ā
ā ā ā
ā ā¼ ā
ā PostgreSQL shared_buffers: 1 GB (25%) ā
ā effective_cache_size: 3 GB (75%) ā
ā ā ā
ā MySQL innodb_buffer_pool_size: 2 GB (50%) ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Redis Decision
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Is Redis a cache or a data store? ā
ā ā ā
ā āāāāāā“āāāāā ā
ā ā ā ā
ā CACHE DATA STORE ā
ā ā ā ā
ā ā¼ ā¼ ā
ā save "" appendonly yes ā
ā noeviction ā allkeys-lru ā everysec ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Summary
| Item | Value |
|---|---|
| PostgreSQL data dir | /var/lib/postgresql/data |
| MySQL data dir | /var/lib/mysql |
| Redis data dir | /data |
| PostgreSQL memory | shared_buffers, effective_cache_size |
| MySQL memory | innodb_buffer_pool_size |
| MySQL durability | innodb_flush_log_at_trx_commit |
| Redis cache | No persistence, allkeys-lru |
| Redis store | AOF everysec |
| Container-specific | shm_size, user:999 |
Key takeaways:
- The named volume is the persistence. The container filesystem is ephemeral, and a database without a volume loses its data when the container is removed. The named volume is managed by Docker, and it survives
docker rm. - The buffer pool must be sized to the container, not the host. PostgreSQL’s
shared_buffersand MySQL’sinnodb_buffer_pool_sizedefault to 128 MB, which causes thrashing on any real workload. Size them to a fraction of the container’s memory limit . - PostgreSQL needs a larger
/dev/shmfor parallel queries. The container’s default is 64 MB, andshm_size: 256mbis the fix . - MySQL’s durability is a deliberate trade-off.
innodb_flush_log_at_trx_commit=1is ACID-compliant;2is faster but loses up to one second on crash. Choose based on the database’s role . - Redis is a cache or a data store, not both. A cache needs
allkeys-lruand no persistence. A data store needs AOF witheverysecandnoeviction. - The bind mount permission problem is real. MySQL runs as UID 999, and a bind mount owned by a different UID fails. The named volume avoids the problem .
Remember: Containerizing a database is not the same as containerizing a web server. The web server is stateless; the database is not. The persistence is the named volume, and without it the container is a temporary scratchpad. The performance is the memory parameters, and the defaults are for development, not for the container’s actual memory limit. The durability is a deliberate trade-off, and the default is the safe setting but not the fast one. PostgreSQL, MySQL, and Redis each have their own parameter names and their own container-specific quirks, but the pattern is the same: a named volume, a memory budget, and a decision about how much durability the workload actually needs. The container is the delivery mechanism; the volume and the configuration are the database.
Stop using slow, ad-bloated tool sites! š¤®
š Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
ā
Finance (Mortgage, Interest, Inflation)
ā
Tech (Base64, JSON, Dev Suite, IP)
ā
Health (BMI, BMR, TDEE)
ā
Productivity (Timer, Workspace, QR)
ā”ļø Fast & Private
š No data leaves your device
š 100% Free
š Use it now: https://tools.kandz.me
š Bookmark itāyouāll need it later!