Table of Contents

Docker Storage

Containers are ephemeral; their filesystems are not meant to survive. Anything that must outlive a container — database files, uploads, logs worth keeping — has to live on a mount. This page covers how Docker stores data, how to choose between the mount types, and the two areas that cause the most trouble in practice: permissions on bind mounts and disk usage that grows without obvious cause.

Table of Contents

Mount Types

Type Managed by Survives container removal Portable Typical use
Named volume Docker Yes Yes Database data, application state
Bind mount You Yes (it is a host path) No Source code in development, host config
tmpfs Kernel (RAM) No N/A Secrets, scratch space

The general rule: named volumes for data the application owns, bind mounts for files you own. A database's data directory belongs in a named volume. A configuration file you edit by hand belongs in a bind mount.

-v vs --mount

Both flags work, but they behave differently in one important way:

# Short form — if /host/path does not exist, Docker CREATES it as a root-owned directory
docker run -v /host/path:/data alpine

# Long form — if the source does not exist, Docker ERRORS
docker run --mount type=bind,source=/host/path,target=/data alpine

The silent-directory-creation behavior of -v turns a typo in a host path into an empty mount, and the application starts with no data and no error. Prefer --mount in anything scripted or committed; it is explicit and it fails loudly.

# --mount syntax
docker run --mount type=volume,source=appdata,target=/var/lib/app myapp
docker run --mount type=bind,source="$(pwd)"/conf,target=/etc/app,readonly myapp
docker run --mount type=tmpfs,target=/tmp,tmpfs-size=100m myapp

Named Volumes

docker volume create appdata
docker volume ls
docker volume inspect appdata
docker volume rm appdata

# Use it
docker run -d --name db -v appdata:/var/lib/postgresql/data postgres:16

Volumes live under /var/lib/docker/volumes/<name>/_data on Linux. You can read that path directly for debugging, but do not write to it while a container is running.

Anonymous Volumes

If an image declares VOLUME /data and you do not supply a mount, Docker creates an anonymous volume with a random name. These accumulate — every docker run of such an image leaves another one behind:

# Find them
docker volume ls -f dangling=true

# Remove the container and its anonymous volumes together
docker rm -v mycontainer

# Always clean up throwaway containers
docker run --rm -v /data alpine

The First-Mount Copy

This behavior explains why named volumes "just work" where bind mounts do not:

Important

When an empty named volume is mounted, Docker copies the image's existing content at that path into the volume, including ownership and permissions. This happens only once, only for named volumes, and only when the volume is empty. Bind mounts never do this — they shadow the image content entirely.

So postgres:16 with a named volume gets a data directory owned by the postgres user automatically, while the same image with an empty bind-mounted host directory gets a root-owned empty directory and fails to initialize.

Read-Only Mounts

docker run -d --mount source=config,target=/etc/app,readonly myapp
docker run -d -v config:/etc/app:ro myapp

# Make the whole container filesystem read-only, with writable exceptions
docker run -d --read-only \
  --tmpfs /tmp \
  --mount source=appdata,target=/var/lib/app \
  myapp

--read-only is a strong hardening measure — see Container Security.

Bind Mounts

# Development: live source code
docker run -d \
  --mount type=bind,source="$(pwd)"/src,target=/app/src \
  -p 3000:3000 \
  node:20 npm run dev

# Read-only host configuration
docker run -d \
  --mount type=bind,source=/etc/myapp/config.yml,target=/etc/app/config.yml,readonly \
  myapp

Mount Propagation

When the host path is itself a mount point that changes, propagation controls whether the container sees it:

docker run -d \
  --mount type=bind,source=/mnt/storage,target=/data,bind-propagation=rslave \
  myapp
Mode Behavior
rprivate Default. Neither side sees the other's new sub-mounts.
rslave Container sees host sub-mounts; host does not see container's.
rshared Both directions propagate.

rslave is what you want when the host mounts removable media or network shares under the bind-mounted path after the container starts.

SELinux Labels

On RHEL, Fedora, CentOS, and other SELinux-enforcing systems, a bind mount is inaccessible until it carries a container-compatible label:

# :z — shared label, multiple containers may access
docker run -v /host/data:/data:z myapp

# :Z — private label, this container only
docker run -v /host/data:/data:Z myapp
Warning

:Z relabels the host directory recursively. Pointing it at /home, /usr, or another system directory will relabel everything underneath and can break the host. Only apply it to directories created for the container.

Permissions and Ownership

This is the most common source of "permission denied" in Docker, and the mechanics are straightforward once stated plainly.

The kernel checks numeric UIDs and GIDs, not names. A container has its own /etc/passwd, so UID 1000 might be node inside and joseph on the host — but they are the same UID, and that is all the kernel compares. There is no translation layer on a bind mount.

# What UID does the container run as?
docker run --rm myapp id

# What owns the host directory?
ls -ln /host/data

If those numbers do not match and the directory is not world-writable, writes fail.

Three Ways to Fix It

1. Run the container as the host user. Best for development bind mounts:

docker run --rm \
  --user "$(id -u):$(id -g)" \
  --mount type=bind,source="$(pwd)",target=/work \
  -w /work \
  alpine touch newfile

The caveat is that the UID may not exist in the container's /etc/passwd, so the shell shows I have no name! and $HOME may be unset. That is cosmetic for most tooling, but software that looks itself up by UID will complain. Supplying --user with a group the image already defines, or passing -e HOME=/tmp, usually settles it.

2. Chown the host directory to the container's UID:

# Find the UID the image uses
docker run --rm postgres:16 id -u postgres    # 999

sudo chown -R 999:999 /srv/postgres-data

3. Use a named volume instead, and let the first-mount copy set ownership correctly. For service data this is nearly always the right answer.

Entrypoint Chown Pattern

Many official images start as root, fix ownership, then drop privileges with gosu or su-exec. If you write your own image and need this:

COPY entrypoint.sh /usr/local/bin/
ENTRYPOINT ["entrypoint.sh"]
#!/bin/sh
set -e
# Fix ownership of the mounted data directory, then drop privileges
chown -R app:app /var/lib/app
exec su-exec app "$@"

This requires the container to start as root, which conflicts with a --user override. Document which model your image expects.

User Namespace Remapping

With userns-remap enabled, container UID 0 maps to an unprivileged host UID, and file ownership on volumes shifts by that offset. This changes every calculation above. See Rootless Docker and User Namespaces.

tmpfs Mounts

tmpfs mounts live in RAM and never touch disk — appropriate for secrets and scratch data:

docker run -d \
  --tmpfs /app/secrets:noexec,nosuid,size=10m \
  --tmpfs /tmp:noexec,nosuid,size=1g \
  myapp

# --mount equivalent
docker run -d \
  --mount type=tmpfs,target=/app/cache,tmpfs-size=536870912,tmpfs-mode=1770 \
  myapp

Always set size — an unbounded tmpfs can consume all host memory. noexec and nosuid should be the default for anything holding data rather than programs.

Note

tmpfs is Linux-only. On Swarm, the equivalent is --mount type=tmpfs in the service definition; for secrets specifically, prefer Swarm secrets, which are already delivered via an in-memory filesystem.

Volume Drivers

The built-in local driver accepts the same options as mount(8), which is enough to attach network storage without any plugin.

NFS

docker volume create \
  --driver local \
  --opt type=nfs \
  --opt o=addr=192.168.1.100,rw,nfsvers=4,hard,timeo=600 \
  --opt device=:/exports/appdata \
  nfs-appdata

docker run -d -v nfs-appdata:/data myapp

In Compose:

volumes:
  nfs-appdata:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=192.168.1.100,rw,nfsvers=4,hard,timeo=600"
      device: ":/exports/appdata"
Tip

Use hard rather than soft for data you cannot afford to corrupt: a soft mount returns I/O errors when the server is unreachable, and many applications handle that badly. hard blocks until the server returns.

CIFS/SMB

docker volume create \
  --driver local \
  --opt type=cifs \
  --opt device=//192.168.1.50/share \
  --opt o=username=svc_docker,password=secret,uid=1000,gid=1000,vers=3.0 \
  smb-share

CIFS has no concept of Unix ownership by default, so uid/gid in the options set what every file appears to be owned by inside the container — which conveniently sidesteps the permission problem above.

Warning

This puts a password in the volume definition, visible via docker volume inspect and in any committed Compose file. Use a credentials file (credentials=/root/.smbcreds) with restrictive permissions instead.

Third-Party Plugins

docker plugin install vieux/sshfs
docker plugin ls
docker volume create -d vieux/sshfs -o sshcmd=user@host:/path -o password=secret sshvol

Plugins exist for most cloud block storage and clustered filesystems. Evaluate them carefully — a volume plugin failure takes the data path down with it.

Storage Drivers and Layers

How Layers Work

An image is a stack of read-only layers. A running container adds a thin writable layer on top. Reads fall through the stack until the file is found; writes go to the writable layer.

The mechanism is copy-on-write: modifying a file that lives in a lower layer first copies the entire file upward, then modifies the copy. Two consequences follow:

  • Changing one byte of a 2 GB file costs a 2 GB copy and 2 GB of disk.
  • Write-heavy workloads on the container filesystem are markedly slower than on a volume.

This is the technical reason databases must use volumes, not merely a stylistic preference.

overlay2

overlay2 is the default and the right choice on every modern Linux distribution:

docker info | grep -A5 'Storage Driver'

It composes a view from several directories under /var/lib/docker/overlay2/:

Directory Role
lowerdir The read-only image layers
upperdir The container's writable layer
merged The unified view the container actually sees
workdir Internal scratch space overlayfs requires
# See the layer directories for a container
docker inspect -f '{{json .GraphDriver.Data}}' mycontainer | jq

Selecting a Driver

Set it in /etc/docker/daemon.json — see Daemon Configuration:

{
  "storage-driver": "overlay2"
}
Driver Status
overlay2 Default. Use this.
fuse-overlayfs For rootless Docker on kernels without unprivileged overlayfs
btrfs / zfs Only when /var/lib/docker is already on that filesystem; enables snapshots
vfs No copy-on-write — full copy per layer. Very slow and space-hungry; a testing fallback only
devicemapper Removed from current releases; migrate off it
Warning

Changing the storage driver makes existing images and containers invisible — the daemon looks for them in a different backend. Export anything you need first, and plan for /var/lib/docker to be rebuilt.

Backing Filesystem

overlay2 requires d_type support in the backing filesystem. ext4 and xfs (formatted with ftype=1) both provide it; XFS formatted without it silently misbehaves:

docker info | grep -i 'backing filesystem'
xfs_info /var/lib/docker | grep ftype

If ftype=0, the filesystem must be recreated — it cannot be changed in place.

Disk Usage and Pruning

Docker disk consumption grows quietly. Images, stopped containers, unused volumes, and the build cache all accumulate, and the build cache in particular can reach tens of gigabytes without anyone noticing.

Finding Where Space Went

# Summary by category
docker system df

# Per-object detail — this is the one that finds the culprit
docker system df -v
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          42        12        18.3GB    12.1GB (66%)
Containers      15        8         1.2GB     840MB (70%)
Local Volumes   23        9         31.4GB    22.8GB (72%)
Build Cache     318       0         14.7GB    14.7GB

Pruning Safely

# Stopped containers
docker container prune

# Dangling images (untagged, unreferenced)
docker image prune

# ALL images not used by a running container — aggressive
docker image prune -a

# Build cache
docker builder prune
docker builder prune --filter 'until=168h'    # older than a week

# Everything except volumes
docker system prune

# Everything, volumes included
docker system prune -a --volumes
Caution

docker system prune does not touch volumes unless you pass --volumes — a deliberate safety default, since volumes hold the data that matters. Conversely, once you do pass --volumes, any volume not attached to a container is deleted permanently. Confirm with docker volume ls -f dangling=true before running it, and never run it unattended on a host where containers are recreated on a schedule: a volume is "unused" during the gap between docker compose down and docker compose up.

Volume Pruning Specifics

# Removes ANONYMOUS unused volumes only
docker volume prune

# Also removes unused NAMED volumes
docker volume prune -a

The default deliberately spares named volumes, on the reasoning that anything you bothered to name is probably data you want.

Scheduled Cleanup

#!/bin/bash
# /usr/local/bin/docker-cleanup.sh
set -euo pipefail

# Conservative: containers, dangling images, and old build cache only.
# Volumes and tagged images are deliberately left alone.
docker container prune -f --filter 'until=24h'
docker image prune -f
docker builder prune -f --filter 'until=168h'

docker system df
# Weekly, via systemd timer or cron
0 4 * * 0 /usr/local/bin/docker-cleanup.sh >> /var/log/docker-cleanup.log 2>&1

Log Files

Container logs are not covered by any prune command and are a frequent cause of a full disk. Cap them in daemon.json:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

See Daemon Configuration and Monitoring and Logging.

Limiting Container Write Layers

On xfs or btrfs backing storage with the appropriate driver, a per-container quota is available:

docker run -d --storage-opt size=10G myapp

This is unsupported on overlay2 over ext4, where it returns an error. Where it is not available, bound growth by capping logs and directing writes to volumes.

I/O Performance

Choose the Right Location for Writes

In descending order of throughput:

  1. tmpfs — RAM, fastest, non-persistent
  2. Named volume — direct filesystem access, no copy-on-write overhead
  3. Bind mount — equivalent to a named volume on Linux
  4. Container writable layer — copy-on-write penalty on every first write

Anything doing sustained writes — databases, message queues, build caches — belongs on a volume.

Docker Desktop

On macOS and Windows, containers run inside a Linux VM and bind mounts cross a filesystem bridge, which is dramatically slower than native. A dependency install into a bind-mounted node_modules can be an order of magnitude slower than the same operation in a volume.

services:
  app:
    volumes:
      - .:/app                 # source: bind mount, needs to be live-editable
      - node_modules:/app/node_modules   # dependencies: volume, stays fast
volumes:
  node_modules:

Enabling VirtioFS in Docker Desktop's settings substantially narrows the gap on macOS. On Windows, keeping project files inside the WSL2 filesystem rather than under /mnt/c matters far more than any Docker setting.

Measuring

# Write throughput to a volume
docker run --rm -v testvol:/data alpine \
  dd if=/dev/zero of=/data/testfile bs=1M count=1024 oflag=direct

# Compare against the container's writable layer
docker run --rm alpine \
  dd if=/dev/zero of=/testfile bs=1M count=1024 oflag=direct

# Live block I/O per container
docker stats --format "table {{.Name}}\t{{.BlockIO}}"

Throttling

docker run -d \
  --device-read-bps /dev/sda:50mb \
  --device-write-bps /dev/sda:50mb \
  --device-write-iops /dev/sda:1000 \
  --blkio-weight 500 \
  myapp

Useful for keeping a batch job from starving interactive services on shared hardware.

Backup and Restore

Backing Up a Volume

docker run --rm \
  -v appdata:/data:ro \
  -v "$(pwd)":/backup \
  alpine tar czf /backup/appdata-$(date +%F).tar.gz -C /data .

Restoring

docker run --rm \
  -v appdata:/data \
  -v "$(pwd)":/backup \
  alpine sh -c 'rm -rf /data/* && tar xzf /backup/appdata-2026-08-01.tar.gz -C /data'
Important

Stop the containers using a volume before backing it up. A tar of a live database directory produces a file that restores into a corrupt database. For databases, use the engine's own dump tool (pg_dump, mysqldump) instead of copying files.

For Compose-managed stacks, see the backup and restore scripts in Docker Compose.

Migrating a Volume Between Hosts

# On the source host
docker run --rm -v appdata:/data:ro alpine tar czf - -C /data . \
  | ssh user@newhost 'docker run --rm -i -v appdata:/data alpine tar xzf - -C /data'

The destination volume must already exist — create it with docker volume create appdata.

Troubleshooting

Symptom Cause and fix
permission denied writing to a bind mount UID mismatch — see Permissions
Bind mount is empty Host path did not exist and -v created it; use --mount to catch this
Data vanished after docker compose down Anonymous volume, or down -v was used; declare named volumes
Volume in use, cannot remove docker ps -a --filter volume=<name> to find the holder
Disk full despite pruning Build cache or container logs — docker system df -v, then cap log size
Changes to image files are slow Copy-on-write on a large file; move it to a volume
no space left on device with free disk Inode exhaustion — df -i

Finding What Holds a Volume

docker ps -a --filter volume=appdata
docker volume inspect appdata

Inspecting Volume Contents

# Read-only look at a volume without disturbing the app
docker run --rm -it -v appdata:/data:ro alpine sh

Disk Full on /var/lib/docker

If /var/lib/docker shares a partition with the OS, a runaway image or log can take the host down. Moving it to its own filesystem is worthwhile on any server that matters:

{
  "data-root": "/srv/docker"
}

Stop the daemon, copy the existing tree with rsync -aHAX /var/lib/docker/ /srv/docker/, then restart. See Daemon Configuration.