Section 3 · Local Environments with Docker Compose
Volumes: Data That Survives
16 min3 min readChoose wisely
Lesson at a glance
Keep Postgres data across restarts with a named volume, seed the database with a bind-mounted init script, and know which Compose commands keep or destroy your data.
- 1Named volumes for database files
- 2Bind mounts for files you edit
- 3tmpfs for scratch data
- 4Volumes and database versions
- 5Which mount for which job
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The week we added Compose to a Skylane project, a developer ran docker compose down -v to “clean up” before a demo. It cleaned up three days of carefully entered test data. Containers are disposable by design, so anything you want to keep must live outside them, and you need to know exactly which command throws it away.
Named volumes for database files
Add a volume to the db service and declare it at the top level:
services:
# api: …as before
db:
image: postgres:17
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: notes
POSTGRES_DB: notes
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:A named volume is storage that Docker creates and manages. Compose prefixes it with the project name, so this one is notes_pgdata. It isn’t part of any container, so removing and recreating db leaves the data in place:
docker compose down # containers and network removed, pgdata kept
docker compose up -d # notes are still there
docker volume ls # notes_pgdata
docker volume inspect notes_pgdataNamed volumes are the right default for database files. Docker sets ownership correctly for the container, you don’t get host filesystem quirks, and on Docker Desktop they’re much faster than bind mounts because they live inside the Linux VM.
Restoring is the same pipe in the other direction: docker compose exec -T db psql -U notes notes < backup.sql. The -T flag turns off the pseudo-terminal so the file can be streamed in. It’s worth trying once on a quiet afternoon, so the first time you restore isn’t the day you need to.
Bind mounts for files you edit
A bind mount maps a path on your machine into the container. Edits on either side show up on the other immediately. That’s what you want for files that live in your repository, like a schema script:
db:
image: postgres:17
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:roThe Postgres image runs every .sql and .sh file in /docker-entrypoint-initdb.d, in name order, only when the data directory is empty. On the very first up, db/init/001-schema.sql creates the notes table. On every later start, the folder is ignored, because the volume already holds an initialised database. When you change the schema after that, write a migration that the app runs, or deliberately wipe the dev volume.
The :ro suffix makes the mount read-only inside the container. Give containers the least access that works.
Bind mounts have two rough edges. On Linux, files are owned by the host user’s UID, which may not match the user inside the container, so writes fail with EACCES. On macOS and Windows, file access crosses the VM boundary and is slower, which is why heavy folders like node_modules should never be bind-mounted from the host. You’ll use a better tool for live code editing, Compose watch, in lesson 3.4.
tmpfs for scratch data
A tmpfs mount lives in memory and disappears when the container stops. It’s useful for scratch files you never want on disk, or for a throwaway test database where speed matters more than survival:
db-test:
image: postgres:17
tmpfs:
- /var/lib/postgresql/dataVolumes and database versions
A volume stores the database’s on-disk format, which belongs to one major version. If you bump postgres:17 to postgres:18 and keep the same volume, the new server refuses to start on the old files. Postgres 18’s image also moved its default data location: it expects a single mount at /var/lib/postgresql (and keeps data in a version-named folder below it) rather than at .../data. Its entrypoint exits with an error explaining this if it finds data, or a mount, at the old path. Read the image’s release notes before bumping a major version.
Which mount for which job
| Need | Use |
|---|---|
| Database files that survive restarts | Named volume |
| Seed scripts or config from the repo | Bind mount, :ro |
| Fast, disposable scratch space | tmpfs |
| Your source code during development | Compose watch (lesson 3.4) |
Your data now outlives your containers. But start the stack from cold and you may see the API fail its first few requests: it starts before Postgres is ready to accept connections. Startup order is next.
Key takeaways
- Named volumes are managed by Docker and outlive containers; use them for database files.
- Bind mounts map a host path into a container; use them for files you edit on the host, such as seed scripts, and mount them read-only when you can.
docker compose downkeeps named volumes;docker compose down -vdeletes them along with all their data.- Scripts in
/docker-entrypoint-initdb.drun only when the Postgres data directory is empty, which means on the very first start. - A database’s on-disk format is tied to its major version; upgrading Postgres across majors needs a dump and restore or
pg_upgrade, not a new tag.