skip to content

How does a Docker Compose stack keep a database's data across `docker compose down` and a later `up`, and what is the difference between a named volume and a bind mount declared in a compose file?

level: middleimportance: should knowfreq 55%

answer

  1. writable layer dies with the container
  2. bare name + top-level volumes: = named volume
  3. ./path = bind mount, shadows image content
  4. named volume seeded from image on first mount
  5. down keeps volumes; down -v deletes them

basics

~20 s

Declare a named volume under the top-level volumes: and mount it at the data path. Named volumes are Docker-managed, survive down, and are only deleted by down -v. Bind mounts map a host path into the container — great for source code, but host-permission and performance sensitive.

solid answer

~50 s

A container's writable layer dies with the container, so anything that must survive recreation has to live on a mount. **Named volume** — declared under top-level `volumes:` and referenced as `pgdata:/var/lib/postgresql/data`. Docker owns the storage (under `/var/lib/docker/volumes/<project>_pgdata`), creates it on first `up`, and keeps it across `down`. Only `docker compose down -v` (or `docker volume rm`) destroys it. Ownership/permissions are initialised from the image's directory, so it "just works" for database images. **Bind mount** — `./src:/app/src`, mapping a host path. The host path wins: it shadows whatever the image had there, it is not initialised from the image, and its UID/GID come from the host, which is where "permission denied" and root-owned files come from. Its purpose is live source reloading and injecting config files. Also useful: `:ro` for read-only config, and an anonymous volume over `node_modules` to stop a bind mount from shadowing image-installed dependencies.

code

yaml · 19 lines
yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpassword
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro

  web:
    build: .
    volumes:
      - ./:/app          # live source reload
      - /app/node_modules # anonymous volume keeps image-installed deps
    tmpfs:
      - /tmp

volumes:
  pgdata:

go deeper

for a junior

Know that a named volume under top-level volumes: keeps database data and that down -v wipes it.

for a middle

Explain the seeding-on-first-mount behaviour, why bind mounts shadow image content, and the anonymous-volume trick for dependency directories.

for a senior

Reason about UID/GID mismatches, :ro config mounts, tmpfs for scratch, external: true guards, and how to back up or reset volume data.

for a principal

Set the team convention: data on named volumes, code on bind mounts only in dev, nothing important reachable by an accidental down -v, and be explicit that local volume semantics do not model production storage.

## Why the writable layer is not storage A container writes to a thin copy-on-write layer stacked on top of the image's read-only layers. That layer is created with the container and deleted with it. `docker compose up` after a config or image change **recreates** containers, so anything written inside the container — a Postgres data directory, uploaded files — disappears. Persistence therefore requires a mount that lives outside the container lifecycle. ## Named volumes ```yaml services: db: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: ``` The short syntax is `SOURCE:TARGET[:MODE]`. Because `pgdata` is a bare name that also appears under the top-level `volumes:` key, Compose treats it as a **named volume**: Docker allocates storage it manages (on Linux, under `/var/lib/docker/volumes/`), prefixed with the project name (`myapp_pgdata`). Properties that matter: - **Lifecycle is independent of containers.** `down` removes containers and networks but leaves the volume; the next `up` reattaches it and the database still has its data. `down -v` removes volumes declared in the file. Anonymous volumes attached to removed containers can be cleaned with `docker compose down --volumes` or `docker volume prune`. - **First-mount initialisation.** When a named volume is empty and is mounted over a directory that has content in the image, Docker copies the image's content (and its ownership) into the volume. This is why `postgres` and `mysql` images work out of the box on a named volume but often fail on an empty bind mount. - **Portable.** No host paths in the file, so the same compose file works on Linux, macOS and Windows, and on Docker Desktop's VM the storage stays inside the VM's filesystem — which is also why it is much faster than a bind mount there. - **Drivers and options.** `driver: local` with `driver_opts` can bind a named volume to a specific path or an NFS export; `external: true` says "this volume already exists, do not create or delete it" — a common guard for data you never want `down -v` to touch. ## Bind mounts ```yaml volumes: - ./src:/app/src - ./nginx.conf:/etc/nginx/nginx.conf:ro ``` A source starting with `.` or `/` is a **bind mount** of a host path. The relative path is resolved against the compose file's directory. Differences from named volumes: - **No initialisation.** The host directory is mounted as-is and completely shadows the image's content at that target. Mounting an empty host directory over `/var/lib/postgresql/data` gives you an empty data directory, not a seeded one. - **Host ownership.** Files carry the host's UID/GID. A container running as a non-root user may be unable to write; conversely a container running as root can create root-owned files in your working tree. The usual fixes are running the container with `user: "${UID}:${GID}"`, chowning in an entrypoint, or moving the data to a named volume. - **Performance.** On Docker Desktop, host↔VM file sharing is a real cost for large trees like `node_modules` or Gradle caches. Long syntax supports `consistency` hints and Docker Desktop offers VirtioFS/gRPC-FUSE; the durable trick is to keep hot directories off the bind mount. - **Purpose.** Live-reload of source code during development, and injecting config files read-only with `:ro`. ## The `node_modules` / build-output trick Bind-mounting `./:/app` over an image that ran `npm ci` at build time hides the image's `/app/node_modules` behind the (probably absent or platform-wrong) host copy. The standard remedy is an anonymous volume on the subpath, which takes precedence over the parent bind mount: ```yaml volumes: - ./:/app - /app/node_modules ``` The same pattern applies to `target/`, `build/` and `.venv`. ## Long syntax and tmpfs Long syntax is explicit and preferred when it matters: ```yaml volumes: - type: volume source: pgdata target: /var/lib/postgresql/data - type: bind source: ./config target: /etc/app read_only: true - type: tmpfs target: /tmp ``` `tmpfs` mounts RAM-backed storage that never touches disk — the right choice for scratch space and secrets material, and a common companion to a `read_only: true` root filesystem. ## Operating the data Backups are taken by running a throwaway container that mounts the volume: `docker run --rm -v myapp_pgdata:/data -v $PWD:/backup alpine tar czf /backup/db.tgz /data` — or, for a database, by using its own dump tool via `docker compose exec`, which is safer than copying live files. To start a stack from scratch, `docker compose down -v` is the reset button; declaring critical volumes as `external: true` is how you make that reset impossible by accident.

  • You bind-mount an empty host directory over a Postgres data directory and the container fails to start. Why does the same path work with a named volume?
    An empty named volume is seeded on first mount with the content and ownership of the image's directory, so the entrypoint finds the layout it expects. A bind mount is never seeded — the host directory shadows the image content exactly as it is, so the entrypoint sees an empty (or wrongly-owned) data directory and errors out.
  • How do you make sure `docker compose down -v` can never delete a particular volume?
    Declare it as external: `volumes: { pgdata: { external: true, name: shared_pgdata } }`. Compose then assumes the volume already exists, refuses to create it, and never removes it — `down -v` only removes volumes the project owns.

A named volume is a storage locker the building manages and hands back next time; a bind mount is a window cut through to your own desk — convenient, but whatever is on your desk is what the room sees.

saying these in an interview costs you the question

  • Expecting data written to the container's filesystem to survive `docker compose up` recreating the container.
  • Believing `docker compose down` deletes named volumes by default.
  • Assuming a bind mount is initialised from the image's directory content the way a named volume is.
  • Bind-mounting the project root over an image that installed dependencies, then blaming Docker when `node_modules` vanishes.
  • Treating bind-mount permission errors as a Docker bug rather than host UID/GID mismatch with the container user.

context