EduFlow

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.

  1. 1Named volumes for database files
  2. 2Bind mounts for files you edit
  3. 3tmpfs for scratch data
  4. 4Volumes and database versions
  5. 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:

compose.yaml
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:

Terminal
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_pgdata

Named 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:

YAML
  db:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro

The 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:

YAML
  db-test:
    image: postgres:17
    tmpfs:
      - /var/lib/postgresql/data

Volumes 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 down keeps named volumes; docker compose down -v deletes them along with all their data.
  • Scripts in /docker-entrypoint-initdb.d run 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

You run docker compose down and then docker compose up -d. The db service mounts the named volume pgdata. What happens to your notes?

Question 2 of 4

You edit db/init/001-schema.sql to add a column, run docker compose up -d, and the column doesn’t appear. Why?

Question 3 of 4

Which mount should hold Postgres’s data in a development stack you want to survive restarts?

Question 4 of 4

You change the db image from postgres:17 to postgres:18 and keep the same pgdata volume. What should you expect?

Practice

Choose wisely

Pick the right mount

You’re reviewing storage choices in the notes-api Compose stack. Select every pairing of need and mount type that is correct.

Pick the right mount