Running a database in Docker is straightforward; running it safely in production requires three specific decisions: where the data lives (named volume), who owns it (UID mapping), and how it is backed up (independent of the container lifecycle). Skip any one of these and you will eventually lose data, be unable to restore it, or find it inaccessible after a container replacement.
Why Stateful Services in Docker Require More Deliberate Configuration
Stateless containers are the natural fit for Docker — replace the container, the service continues as before. Stateful containers (databases, file storage, session stores) require the data to outlive the container. This changes the entire operational model.
The failure sequence for an improperly configured stateful container is always the same: the database runs correctly for days or weeks; a deployment replaces the container; all data is gone. The cause is invariably the same: data was stored in the container’s writable layer rather than a named volume. The writable layer is discarded on ‘docker rm’. Named volumes are not.
Beyond data persistence, stateful services in Docker face three additional production challenges: UID ownership mismatches between the container process and the volume directory cause the database to fail to start; file locking behaviour of database engines (fsync, advisory locks, WAL) may interact unexpectedly with some storage backends; and backup procedures must operate independently of the container lifecycle, because a backup script that stops the container to create a consistent snapshot also takes the service offline.
Each of these challenges has a standard solution. The goal of this article is to provide those solutions in a directly applicable form for the most common stateful services: PostgreSQL, MySQL, Redis, and file upload storage.
Storage Configuration for Production Stateful Services
# PostgreSQL with named volumes and correct configuration
# Create volumes first (makes ownership management explicit)
docker volume create pg-data
docker volume create pg-wal
# Pre-set ownership to match PostgreSQL container UID (999)
docker run --rm \
-v pg-data:/data \
-v pg-wal:/wal \
alpine sh -c 'chown -R 999:999 /data /wal'
# Run PostgreSQL with all volume mounts
docker run -d \
--name postgres \
--restart unless-stopped \
--memory=2g --memory-swap=2g \
--stop-timeout 120 \
-v pg-data:/var/lib/postgresql/data \
-v pg-wal:/var/lib/postgresql/wal_archive \
-e POSTGRES_USER=appuser \
-e POSTGRES_DB=appdb \
-e POSTGRES_PASSWORD_FILE=/run/secrets/pgpass \
--tmpfs /run/secrets:size=1m \
--health-cmd='pg_isready -U appuser -d appdb || exit 1' \
--health-interval=30s \
--health-start-period=30s \
postgres:15
# MySQL with named volume
docker volume create mysql-data
docker run -d \
--name mysql \
--restart unless-stopped \
--memory=1g --memory-swap=1g \
--stop-timeout 60 \
-v mysql-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql_root \
-e MYSQL_DATABASE=appdb \
--tmpfs /run/secrets:size=1m \
--health-cmd='mysqladmin ping -h localhost --silent || exit 1' \
--health-interval=30s \
mysql:8.0
# Redis with persistence enabled
docker volume create redis-data
docker run -d \
--name redis \
--restart unless-stopped \
--memory=512m --memory-swap=512m \
-v redis-data:/data \
redis:7 redis-server --appendonly yes --appendfsync everysec \
--health-cmd='redis-cli ping || exit 1' \
--health-interval=15s \
redis:7The –appendonly yes flag enables Redis AOF persistence. Without it, Redis only writes RDB snapshots and may lose up to 5 minutes of data on a crash. –appendfsync everysec provides a balance between durability (maximum 1 second of data loss) and performance (one fsync per second rather than one per write).
Storage Configuration Decisions
| Flag / Parameter | Description | Security Note |
|---|---|---|
Named volume for data directory | All database data directories must be stored in named volumes. /var/lib/postgresql/data (PostgreSQL), /var/lib/mysql (MySQL), /data (Redis with persistence). Named volumes are stored at /var/lib/docker/volumes/<name>/_data and survive container removal. They must be explicitly deleted with 'docker volume rm' — accidental deletion requires explicit action, not just 'docker rm'. | Named volume contents are accessible to any process running as root on the host at /var/lib/docker/volumes/. For databases containing sensitive data, encrypt the underlying filesystem. On Linux, use LUKS encryption for the partition containing /var/lib/docker/. The volume contents are not encrypted by Docker. |
--stop-timeout for graceful shutdown | Database containers need more than the default 10-second stop timeout to flush writes and close connections properly. PostgreSQL: 120 seconds minimum for a busy server with active connections. MySQL/InnoDB: 60-120 seconds for InnoDB buffer pool flush. Redis with AOF: 30 seconds minimum for final AOF write. SIGKILL after insufficient stop timeout causes an 'unclean shutdown' condition that requires crash recovery on next start. | An unclean shutdown of PostgreSQL requires a crash recovery phase on next start (replaying WAL). For MySQL/InnoDB, it triggers the InnoDB recovery procedure. Both are safe but add startup time. More critically, a crash during shutdown can corrupt the write-ahead log if the data volume is network-attached and has high latency. Use local disk for database volumes when possible. |
Volume backup with temporary container | Backup the volume by running a temporary container that mounts the volume read-only and creates an archive. For live database backups, use the database's native backup tool (pg_dump, mysqldump) rather than a filesystem snapshot, as filesystem snapshots of an active database may not be consistent. For offline backups (container stopped), filesystem snapshots are safe. | Database backup files contain all application data in a readable format. Backup archives must be encrypted before storage or transmission. Use 'tar czf – /source | openssl enc -aes-256-cbc -pass file:/backup/key.pem -out /dest/backup.tar.gz.enc' for encrypted archives. Store backup encryption keys separately from backup files. |
Additional considerations | When configuring Docker for production, also consider resource limits (–memory, –cpus), restart policies (–restart unless-stopped), logging configuration (–log-opt max-size), and network isolation. Each parameter has production implications beyond the primary use case documented in this guide. | Audit all container runtime parameters against the CIS Docker Benchmark periodically. Default values change across Docker versions and may not be appropriate for production security requirements. |
Production Database Backup and Restore Procedures
PostgreSQL Logical Backup with pg_dump
For most production PostgreSQL deployments, pg_dump provides consistent logical backups without taking the database offline. It captures a consistent snapshot of the database state at the moment the dump begins, even while other transactions continue.
# Live backup using pg_dump (no downtime)
docker exec postgres pg_dump \
-U appuser \
-d appdb \
--format=custom \
--compress=9 \
> /backup/appdb-$(date +%Y%m%d-%H%M%S).pgdump
# Verify the backup is not empty
ls -lh /backup/*.pgdump | tail -1
# Restore from pg_dump backup
# Create a new database first (or restore to an existing one)
docker exec postgres psql -U appuser -c 'CREATE DATABASE appdb_restore;'
docker exec -i postgres pg_restore \
-U appuser \
-d appdb_restore \
--no-owner \
--role=appuser \
< /backup/appdb-20260101-0000.pgdump
# Physical backup with pg_basebackup (for large databases)
docker exec postgres pg_basebackup \
-U replication \
-D /backup/pg_basebackup-$(date +%Y%m%d) \
-Ft --gzip --checkpoint=fast -PVolume-Level Backup (Database Stopped)
For databases that cannot be dumped while running (or for full filesystem-consistent backups), stop the database container first, take the volume backup, then restart. This approach works for any database engine and produces a bit-for-bit copy of the data directory.
#!/bin/bash
# Volume-level backup with minimal downtime
DB_CONTAINER=postgres
VOLUME_NAME=pg-data
BACKUP_DIR=/backup
echo "Stopping $DB_CONTAINER for consistent backup..."
docker stop --time=120 $DB_CONTAINER
echo "Creating archive of volume $VOLUME_NAME..."
docker run --rm \
-v $VOLUME_NAME:/source:ro \
-v $BACKUP_DIR:/dest \
alpine tar czf /dest/pg-data-$(date +%Y%m%d).tar.gz -C /source .
echo "Restarting $DB_CONTAINER..."
docker start $DB_CONTAINER
# Wait for health check to pass
for i in $(seq 1 12); do
status=$(docker inspect --format='{{.State.Health.Status}}' $DB_CONTAINER 2>/dev/null)
[ "$status" = "healthy" ] && echo "Database is healthy. Backup complete." && exit 0
echo "Waiting for healthy status ($i/12)..."
sleep 5
done
echo "WARNING: database did not reach healthy status after 60 seconds"Data Security for Containerised Databases
Databases in Docker containers face all the same security requirements as traditional database installations plus additional Docker-specific exposures. The volume containing database files is accessible to root on the host. Port publishing for databases should always bind to localhost. Container user should match the database process UID. Network isolation should prevent the database from being reachable from the web tier directly.
A specific Docker-related risk: environment variables used to pass database passwords (POSTGRES_PASSWORD, MYSQL_ROOT_PASSWORD) appear in ‘docker inspect’ output by anyone with Docker socket access. Use _FILE variants (POSTGRES_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE) with tmpfs-mounted secret files to avoid credentials appearing in container configuration metadata. Additional considerations apply when running Docker in production: monitoring container health metrics, setting up centralised log aggregation, implementing image signing and verification workflows, and auditing container runtime configuration against CIS Docker benchmarks. Each of these layers adds defence in depth beyond the controls described above. Review the full security posture regularly as Docker releases update default security settings and new CVEs are discovered in container runtime components.
- Never publish database ports to all interfaces. MySQL port 3306, PostgreSQL port 5432, and Redis port 6379 published with '-p 3306:3306' (no host IP) are accessible from the internet. Always bind to localhost: '-p 127.0.0.1:3306:3306' or use Docker networks without port publishing.
- Environment variable passwords appear in 'docker inspect' output and docker history. Use the _FILE variant (POSTGRES_PASSWORD_FILE=/run/secrets/pass) with a tmpfs-mounted secret file that contains only the password. The password is in memory only and does not appear in container metadata.
- Database volume backup files contain all application data in a readable form. Encrypt backup archives before writing to disk or transmitting to remote storage. Store backup encryption keys in a separate location from the backups themselves.
- Volume permissions must be set before first container start. Once a database engine initialises its data directory with specific ownership, changing the UID requires either recreating the volume or running chown inside the container — both require database downtime.
Practical Examples
Complete PostgreSQL Production Setup with Backup
#!/bin/bash
# Complete PostgreSQL production deployment
# 1. Create volumes
docker volume create pg-data
docker volume create pg-backups
# 2. Set correct ownership (PostgreSQL UID is 999 in official image)
docker run --rm -v pg-data:/data alpine chown -R 999:999 /data
# 3. Create secret (tmpfs)
mkdir -p /run/pg-secrets
echo 'very-strong-password-here' > /run/pg-secrets/pgpass
chmod 600 /run/pg-secrets/pgpass
# 4. Run PostgreSQL
docker run -d \
--name postgres \
--restart unless-stopped \
--memory=2g --memory-swap=2g \
--stop-timeout 120 \
-v pg-data:/var/lib/postgresql/data \
-v /run/pg-secrets:/run/secrets:ro \
--network app-backend \
--health-cmd='pg_isready -U appuser || exit 1' \
--health-interval=30s \
--health-start-period=60s \
-e POSTGRES_USER=appuser \
-e POSTGRES_DB=appdb \
-e POSTGRES_PASSWORD_FILE=/run/secrets/pgpass \
postgres:15
# 5. Schedule daily backups via cron
# 0 2 * * * docker exec postgres pg_dump -U appuser -Fc appdb > /backup/pg-$(date +\%Y\%m\%d).pgdump 2>/var/log/pg-backup.logThis setup uses a host-mounted secrets directory (:ro) rather than env variables for the password, ensuring it does not appear in docker inspect output. The network is set to app-backend only — no port publishing means the database is unreachable from outside the Docker network.
Quick Reference: Key Commands and Options
# Inspect current configuration
docker inspect <container> --format='{{json .HostConfig}}' | python3 -m json.tool
# View container resource usage
docker stats <container> --no-stream
# Check container logs
docker logs <container> --tail 50 --timestamps
# Verify running processes inside container
docker exec <container> ps aux
# Check container environment variables
docker exec <container> env | sortThese diagnostic commands work across all container scenarios: ‘docker inspect’ shows the full HostConfig including all runtime parameters, ‘docker stats’ shows real-time resource consumption, and ‘docker logs’ shows output from the main process. Use these as the starting point for any container investigation before diving into more specific tooling.
Persistent Storage Failures
Problem: Database container exits immediately with 'Permission denied' on data directory
Solution: The named volume was created with root ownership but the container process runs as a non-root UID. Fix before starting the database: ‘docker run –rm -v
Problem: Data was lost after 'docker rm' even though a volume was specified
Solution: The volume was an anonymous volume, not a named volume. Anonymous volumes (-v /var/lib/postgresql/data without a name prefix) are created with a generated ID and are deleted by ‘docker rm -v’ or pruned by ‘docker volume prune’. Always use named volumes: ‘-v pg-data:/var/lib/postgresql/data’ where ‘pg-data’ is the explicit name. Verify volumes on a running container: ‘docker inspect
Problem: PostgreSQL refuses to start after a container crash with 'lock file postmaster.pid already exists'
Solution: The postmaster.pid file was not cleaned up after the unclean shutdown. Remove it from the volume: ‘docker run –rm -v pg-data:/data alpine rm -f /data/postmaster.pid’. Then start the container normally. PostgreSQL will perform crash recovery from WAL automatically. This is not data corruption — it is normal crash recovery.
Three Requirements Before Any Database Goes Into Production in Docker
Every database container in production needs three things: a named volume for the data directory (not an anonymous volume, not the container writable layer), correct UID ownership on the volume before first start, and a backup procedure that runs independently of the container and is tested for restore. None of these are defaults. All three must be explicitly configured before the first write reaches the database.
- Named volumes (not anonymous volumes) must be used for all database data directories. Verify with ‘docker inspect
–format={{range .Mounts}}{{.Name}}{{end}}’ — an empty name indicates an anonymous volume that will be lost on docker rm -v or prune. - Set –stop-timeout to 120 seconds for PostgreSQL and 60-120 seconds for MySQL/InnoDB. The default 10-second timeout causes unclean shutdowns, which trigger crash recovery on the next start and add startup latency.
- Test backup restore before the first production incident. A backup that has never been restored is a hypothesis, not a backup. Schedule monthly restore tests to a separate container and verify data integrity.
Related Docker Topics
the difference between named, anonymous, and bind-mount volumes • why the container writable layer is the wrong place for database data • recovering a MySQL database after crash or data corruption • fixing UID mismatches between container processes and volume ownership
Running Databases in Docker Without a Backup Strategy?
INTRAM configures production database containers with correct storage, backup procedures, and recovery testing for MySQL, PostgreSQL, and Redis.
Get Database Container Help