Running Databases in Containers with Persistent Volumes
A database tutorial for Docker: why the writable layer loses data, how to set up named volumes for Postgres and MySQL, fix ownership problems, back up a running DB, and decide when to go managed.
Published on • October 17, 2026
AI Assistant

You rebuild an image, docker compose up -d recreates the database container, and every row is gone. The volume was never wrong - the mount path was. Docker’s docs for the Postgres image spell out a trap that has burned a lot of people: mount at the wrong directory and the container runtime quietly creates an anonymous volume that is never reused across container re-creations, so your intended named volume sits empty while the data goes somewhere else.
Getting a database to survive container churn is mostly about three things: putting the data outside the writable layer, getting the mount path and ownership exactly right, and having a backup that does not depend on the container still running. This tutorial covers the full workflow for PostgreSQL, with the equivalent notes for MySQL, and ends with the honest question of when a containerized database is the wrong answer at all.
Note this post is about the database workflow specifically - what to mount, what breaks on rebuild, how to back up. For the differences between named volumes, bind mounts and tmpfs mounts, see this blog’s earlier comparison of Docker volume types.
Why the writable layer is wrong for data
Docker’s storage documentation is blunt about it: by default, all files created inside a container live on a writable container layer sitting on top of the read-only image layers, and data written there does not persist when the container is destroyed. The layer is unique per container, and extracting data from it to the host or another container is not straightforward.
Two more reasons beyond durability:
- Performance. Writing into the writable layer goes through the storage driver’s union filesystem; volumes write directly to the host filesystem. Docker explicitly calls volumes the better choice for high-performance I/O.
- Lifecycle. A volume’s contents exist outside the lifecycle of any single container.
docker rmthe container, recreate it from a new image, move it to another host - the volume is still there.
Volumes are Docker’s preferred mechanism for persisting container data, manageable through the CLI or API, shareable across containers, and easier to back up or migrate than bind mounts.
The Compose setup for Postgres
Start with the shape that works:
services:
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_DB: appdb
secrets:
- postgres_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 10s
timeout: 5s
retries: 6
secrets:
postgres_password:
file: ./secrets/postgres_password.txt
volumes:
pgdata:
Why each piece is there:
POSTGRES_PASSWORDis the only required variable for the official image, and it must not be empty or undefined. The_FILEsuffix variant loads the value from a file - here a Docker secret - which keeps credentials out ofcompose.yamland image history.- The named volume
pgdatais declared in the top-levelvolumes:section so Compose creates it once and reuses it acrossupcycles. pg_isreadyhealthcheck turns “is the database accepting connections” into machine-readable state, which anything that depends on the DB (Composedepends_on: condition: service_healthy, a migration job) can wait on.
Mount path is the critical line, and it changed recently.
The mount path rule (this is what bites)
For PostgreSQL 17 and below, the image docs say to mount the data volume at /var/lib/postgresql/data and not at /var/lib/postgresql. The Dockerfile declares a volume at the former path; if you mount elsewhere, the runtime creates an anonymous volume at the declared path, data is written there, and it does not persist when the container is deleted and re-created.
For PostgreSQL 18 and above, this was deliberately changed: PGDATA became version-specific (for 18 it is /var/lib/postgresql/18/docker) and the declared VOLUME moved to /var/lib/postgresql, so you should target mounts at /var/lib/postgresql. The stated motivation is enabling the faster pg_upgrade --link workflow when upgrading major versions. Users can opt into the new layout on older releases by setting PGDATA explicitly and mounting /var/lib/postgresql - but then existing data has to be moved into a PG_MAJOR/docker subdirectory first.
# Postgres 18+
services:
db:
image: postgres:18
volumes:
- pgdata:/var/lib/postgresql
Mix the two conventions and you get the worst failure mode in this whole topic: a container that starts fine, initializes an empty database, and serves it - while your real data sits in a volume nothing is reading.
Verify after the first start rather than trusting the YAML:
$ docker inspect db --format '{{json .Mounts}}' | jq .
[
{
"Type": "volume",
"Name": "redlinesoft-blog_pgdata",
"Source": "/var/lib/docker/volumes/redlinesoft-blog_pgdata/_data",
"Destination": "/var/lib/postgresql/data",
"RW": true
}
]
$ docker exec db psql -U postgres -c '\dt' appdb
If Destination is not the path you intended, stop and fix it before writing any data.
Ownership and permission issues
Two different permission problems show up, and they look identical from the outside (“permission denied” on startup).
1. The database’s own user must own PGDATA. On startup the Postgres entrypoint does a chown -R postgres "$PGDATA" and only runs initdb if the directory is empty - this pattern appears verbatim in Docker’s best-practices guide, which uses the Postgres entrypoint as its example of a helper script that ends in exec gosu postgres "$@" so the server becomes PID 1. So a fresh named volume works out of the box: Docker copies the image’s pre-existing content into an empty volume by default (disable with volume-nocopy), and the entrypoint fixes ownership.
2. Running as an arbitrary user needs the host directory to match. The image docs are explicit: postgres does not care what UID it runs as as long as the owner of PGDATA matches, but initdb does care and needs the user to exist in /etc/passwd. Trying --user 1000:1000 against a fresh volume fails with initdb: could not look up effective user ID 1000: user does not exist. MySQL’s image has the same contract from the other direction: it can run with --user 1000:1000 against a bind-mounted directory if - and only if - that directory’s permissions are already set appropriately, which the docs demonstrate with a host directory owned by 1000:1000.
Practical rule: with named volumes, use the image’s default user and let the entrypoint handle it. If you must use a bind mount or a specific UID, chown the host directory to that UID first, once, before the first start.
What actually breaks on rebuild
Beyond the mount path, these are the behaviors to design around:
- Environment variables only apply to an empty data directory. The Postgres image warns that its Docker-specific variables have effect only when the container starts with an empty data directory; any pre-existing database is left untouched. Same for MySQL: variables are ignored if the data directory already contains a database. So “I changed
POSTGRES_PASSWORDin compose and it did nothing” is expected behavior, not a bug. - Init scripts run exactly once. Files in
/docker-entrypoint-initdb.d(.sql,.sql.gz,.sh) execute afterinitdbonly on first initialization. They run in sorted name order and are skipped forever after. Worse, if a script fails and the orchestrator restarts the container against the now-initialized directory, it will not retry them. - Recreating the container is not recreating the data.
docker compose up -d --force-recreateleaves the named volume alone - which is the point - butdocker compose down -vdeletes named volumes too. That-vis the flag that quietly wipes your database. - Anonymous volumes accumulate. An image declaring
VOLUMEwith no named mount creates anonymous volumes that persist after container removal unless you used--rm. Ifdocker volume lsshows a pile of random-name volumes, that is the leftover-trap symptom.
Backup and restore with a running database
Two backup layers, and they answer different questions.
Logical dump (preferred for Postgres)
Take it through the running server so you get a consistent, version-portable dump:
$ docker exec db sh -c 'exec pg_dumpall -U postgres --clean --if-exists' > ./backups/appdb-$(date +%F).sql
$ docker exec -i db sh -c 'exec psql -U postgres -d appdb' < ./backups/appdb-2026-10-17.sql
MySQL’s docs give the same shape for its image:
$ docker exec some-mysql sh -c 'exec mysqldump --all-databases -uroot -p"$MYSQL_ROOT_PASSWORD"' > all-databases.sql
$ docker exec -i some-mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"' < all-databases.sql
This works regardless of where the volume lives and is the only sane option when you later move to a managed instance.
Volume-level backup (fast, crash-consistent at best)
Docker’s own procedure uses --volumes-from to tar the volume through a helper container:
$ docker run --rm --volumes-from db -v "$(pwd)/backups:/backup" alpine \
tar czf /backup/pgdata-$(date +%F).tar.gz /var/lib/postgresql/data
Restoring is the mirror image - untar into a fresh container’s volume before it initializes:
$ docker run --rm --volumes-from db -v "$(pwd)/backups:/backup" alpine \
sh -c 'cd /var/lib/postgresql/data && tar xzf /backup/pgdata-2026-10-17.tar.gz --strip 1'
The caveat: copying files out of a live Postgres data directory while it is writing is not a consistent backup - WAL and data files can be captured mid-transaction. Use it as a fast incremental safety net or stop the container first; rely on pg_dump for anything you intend to restore. Automate both, and test the restore - an untested backup is a hypothesis.
For scheduling, docker volume inspect gives you the host-side mountpoint if an external agent wants to snapshot, and docker volume ls / docker volume prune are how you keep track of what exists (prune removes unused volumes - run it only when you are certain nothing references them).
Healthchecks and dependent services
A database container reports “running” long before it accepts connections - MySQL’s docs call this out directly: no connections are accepted until initialization completes, which is exactly what happens when Compose starts everything at once. Mitigations:
- A real healthcheck (
pg_isreadyfor Postgres; for MySQL,mysqladmin pingagainst the configured user) so orchestrators can gate onservice_healthy. - A connect-retry loop in the application or migration job, which the MySQL docs recommend for clients that cannot wait gracefully.
shm_sizefor Postgres under Compose - the image’s own example setsshm_size: 128mb, since the default/dev/shmis small for parallel query workers.
When to use a managed database instead
Containerized databases are right for local development, CI, single-node internal tools, and staging. They are the wrong default when the workload needs what a managed service sells you:
| Concern | Container + volume | Managed DB |
|---|---|---|
| Backups | You script pg_dump and hope you tested restore | Automated, point-in-time, verified |
| Upgrades | You run pg_upgrade or dump/restore | Minor versions automated |
| High availability | One host holds one volume | Multi-AZ, replicas included |
| Patching | You track base image updates | Provider’s job |
| Scale | Vertical only, or rewrite | Read replicas, elastic storage |
| Cost shape | Idle capacity you pay for anyway | Pay for what you use |
A practical middle path: develop against a container, run production on managed - and make the logical dump the migration boundary, since it works identically across both. The moment you have real traffic and no tested restore procedure, the volume is a liability rather than a feature.
Checklist
- Database data lives in a named volume, never in the container’s writable layer.
- Mount path matches the major version:
/var/lib/postgresql/datafor Postgres 17 and below,/var/lib/postgresqlfor 18+;/var/lib/mysqlfor MySQL. -
docker inspectconfirms the mount destination on first start - before writing real data. - Credentials come from
POSTGRES_PASSWORD_FILE/MYSQL_ROOT_PASSWORD_FILEor secrets, not committed YAML. - A real healthcheck (
pg_isready,mysqladmin ping) gates dependent services, plus a retry loop for slow first init. - Automated
pg_dump/mysqldumpbackups with a restore that has been rehearsed; volume tars are a supplement, not the primary. - The team knows that
docker compose down -vdeletes the database, and env-var/init-script changes only apply to an empty data directory. - There is a written answer to “when does this move to a managed instance?” before it is an incident.
Sources: Docker docs - Storage, Docker docs - Volumes, Docker CLI - docker volume, Docker docs - Building best practices, postgres Docker Official Image, mysql Docker Official Image