A container’s filesystem is temporary: remove the container and everything written inside it is gone. That’s by design — containers should be disposable. Data that must survive (databases, uploads, configuration) belongs outside the container, in a volume or a bind mount.

Three ways to store data

Type Where data lives Managed by Best for
Named volume /var/lib/docker/volumes/<name>/_data Docker Databases, app data
Bind mount Any host path you choose You Config files, source code in development
tmpfs RAM only Kernel Temporary, sensitive data

Named volumes

docker volume create pgdata
docker run -d --name db \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:17

If the volume doesn’t exist, docker run -v pgdata:… creates it. Remove and recreate the container — even with a newer image — and the data is still there:

docker rm -f db
docker run -d --name db -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:17

A handy property: when an empty named volume is mounted over a directory that has content in the image, Docker copies that content into the volume first. Bind mounts don’t do this.

Inspect and manage

docker volume ls
docker volume inspect pgdata          # shows Mountpoint
docker system df -v                   # size of each volume
docker volume rm pgdata               # only if no container uses it

Bind mounts

Mount a host directory or file:

docker run -d -p 127.0.0.1:8080:80 \
  -v /srv/site/html:/usr/share/nginx/html:ro \
  -v /srv/site/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
  nginx:stable
  • :ro makes it read-only inside the container — use it whenever the container shouldn’t write.
  • The host path must exist; otherwise Docker creates an empty directory (even when you meant a file), a classic source of confusion.

The longer --mount syntax is more explicit and errors out instead of creating missing paths:

--mount type=bind,source=/srv/site/html,target=/usr/share/nginx/html,readonly

In Docker Compose

services:
  app:
    image: ghcr.io/example/app:2
    volumes:
      - ./config:/app/config:ro      # bind mount, relative to compose.yaml
      - uploads:/app/uploads         # named volume
  db:
    image: mariadb:11
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  uploads:
  dbdata:

Compose prefixes named volumes with the project name (myapp_dbdata). docker compose down keeps them; docker compose down -v deletes them. See the Docker Compose tutorial.

Permissions: the most common problem

Processes inside containers run as some UID (often root, sometimes a dedicated user like 999 for databases or 1000 for Node images). With bind mounts, the numeric UID must have access to the host directory:

docker run --rm node:22-slim id        # see which user the image runs as
sudo chown -R 1000:1000 /srv/app/data  # match it on the host

Symptoms of a mismatch: EACCES: permission denied, mkdir: cannot create directory, or a database refusing to start. Named volumes avoid most of these problems because the image’s entrypoint can set ownership on first use.

On SELinux systems (AlmaLinux, Rocky), add :z (shared) or :Z (private) to bind mounts so Docker relabels them: -v /srv/data:/data:Z.

tmpfs mounts

docker run --tmpfs /app/cache:rw,size=64m myapp

Fast, never written to disk, gone when the container stops.

Back up a volume

Run a temporary container that mounts the volume and writes a tarball to the host:

docker run --rm -v pgdata:/data:ro -v "$PWD":/backup debian:stable \
  tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .

Restore into a (new) volume:

docker volume create pgdata
docker run --rm -v pgdata:/data -v "$PWD":/backup debian:stable \
  tar xzf /backup/pgdata-2026-11-04.tar.gz -C /data

For databases, stop the container first or — better — use the database’s own dump tool (pg_dump, mysqldump) through docker exec. Copying the files of a running database can produce an inconsistent backup:

docker exec db pg_dump -U postgres appdb | gzip > appdb-$(date +%F).sql.gz

More in backing up Docker volumes.

Clean up unused volumes

docker volume ls -f dangling=true     # volumes not used by any container
docker volume prune                   # remove anonymous unused volumes
docker volume prune -a                # remove ALL unused volumes, named ones too

Careful: a stack that’s currently down has no containers, so its volumes count as unused. Double-check before pruning. See docker prune.

Where to put Docker data

Volumes live under Docker’s data root (/var/lib/docker by default). If your server has a separate data disk, move the data root there, or use bind mounts on that disk for large data sets. A Storage VPS with a large disk suits volume-heavy setups like Nextcloud or media servers.

Quick reference

docker volume create NAME
docker volume ls
docker volume inspect NAME
docker volume rm NAME
docker volume prune
docker run -v NAME:/path IMAGE           # named volume
docker run -v /host/path:/path:ro IMAGE  # bind mount, read-only
docker run --tmpfs /path IMAGE           # tmpfs

Next: Docker networking and the Docker commands cheat sheet.

  • Docker
  • Volumes
  • Containers