Containers are ephemeral by design. When a container stops, every file it created or modified inside its writable layer is gone. This is fine for stateless workloads, but databases, configuration files, uploaded media, and log data all need to survive container restarts and replacements. Docker volumes solve this problem — they provide persistent storage that exists outside the container's filesystem and outlives the container itself.
This guide covers the three types of Docker mounts, how to configure them in Compose stacks, backup strategies, permission gotchas, and storage drivers for production.
Named volumes are the recommended way to persist data in Docker. Docker manages the volume's lifecycle and storage location — you don't need to care about host paths.
# Create a named volume
docker volume create postgres-data
# List all volumes
docker volume ls
# Use a volume in a container
docker run -d \
--name postgres \
-v postgres-data:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:16
The -v postgres-data:/var/lib/postgresql/data flag mounts the named volume postgres-data at the container path /var/lib/postgresql/data. Any data the container writes to that path is stored in the volume.
docker volume inspect postgres-data
[
{
"CreatedAt": "2026-02-19T10:30:00Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/postgres-data/_data",
"Name": "postgres-data",
"Options": {},
"Scope": "local"
}
]
The Mountpoint shows where Docker stores the volume data on the host. On Linux, it's always under /var/lib/docker/volumes/. You can access the data directly at that path, but it's better practice to use docker cp or a helper container for backups.
Named volumes persist until you explicitly remove them:
# Remove a volume (fails if any container is using it)
docker volume rm postgres-data
# Force remove (even if referenced by stopped containers)
docker volume rm -f postgres-data
Stopping or removing a container does not delete its volumes. This is intentional — your data survives container upgrades.
Bind mounts map a specific host directory into the container. Unlike named volumes, you control exactly where data lives on the host filesystem.
# Mount current directory into the container
docker run -d \
--name nginx \
-v /home/user/website:/usr/share/nginx/html:ro \
-p 8080:80 \
nginx:alpine
The :ro suffix makes the mount read-only inside the container. The container can read the files but cannot modify them.
Development hot-reload — mount source code so changes are reflected without rebuilding:
docker run -d \
--name dev-app \
-v /home/user/project/src:/app/src \
-p 3000:3000 \
node:20-alpine npm run dev
Configuration injection — mount config files without baking them into the image:
docker run -d \
--name nginx \
-v /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
-v /etc/nginx/conf.d:/etc/nginx/conf.d:ro \
nginx:alpine
Bind mounts are the #1 source of permission issues in Docker. The problem: files on the host have a UID/GID, and the process inside the container runs as a different UID/GID.
# Host: files owned by user 1000
ls -ln /home/user/data/
# -rw-r--r-- 1 1000 1000 4096 Feb 19 10:00 config.yml
# Container: process runs as UID 999 (e.g., postgres)
# Result: permission denied when writing to /home/user/data/
Fix 1: Match UIDs between host and container:
# Find the UID the container process expects
docker run --rm postgres:16 id
# uid=999(postgres) gid=999(postgres)
# Set host directory ownership to match
sudo chown -R 999:999 /home/user/data/
Fix 2: Run the container with a specific user:
docker run -d \
--user 1000:1000 \
-v /home/user/data:/app/data \
my-app
Tip: Use the chmod Calculator to figure out the exact permission bits you need for bind-mounted directories.
tmpfs mounts store data in memory only. Nothing is written to the host filesystem or the container's writable layer. When the container stops, the data is gone.
# Store secrets that should never touch disk
docker run -d \
--name app \
--tmpfs /run/secrets:rw,noexec,nosuid,size=64m \
my-app
# High-speed scratch space for processing
docker run -d \
--name processor \
--tmpfs /tmp:rw,size=256m \
data-processor
# Set size limit and mount options
docker run -d \
--mount type=tmpfs,destination=/app/cache,tmpfs-size=128m,tmpfs-mode=1770 \
my-app
Key options:
tmpfs-size — maximum size in bytes (default: unlimited, uses available RAM)tmpfs-mode — file permission mode in octal (default: 1777)Use cases for tmpfs: sensitive credentials, session tokens, temporary build artifacts, and any data that must not persist after the container stops.
| Feature | Named Volume | Bind Mount | tmpfs |
|---|---|---|---|
| Managed by | Docker | You (host path) | Kernel (RAM) |
| Persists after container removal | Yes | Yes (host files remain) | No |
| Location on host | /var/lib/docker/volumes/ | Any host path | Memory only |
| Pre-populated with image data | Yes (first mount) | No (host content wins) | No |
| Supports volume drivers | Yes | No | No |
| Performance | Native (Linux), near-native (macOS/Win) | Native (Linux), slow on macOS/Win | Fastest (RAM) |
| Portability | Works across hosts | Path must exist on host | Works anywhere |
| Best for | Databases, persistent app data | Dev hot-reload, config injection | Secrets, temp files |
Rule of thumb: Use named volumes for anything that needs to persist. Use bind mounts for development and config files. Use tmpfs for sensitive or throwaway data.
Docker Compose makes volume management declarative. You define volumes once in the top-level volumes key and reference them in services.
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data:
The top-level volumes: block declares named volumes. postgres-data: with no options creates a simple local volume. The bind mount ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro uses a relative path — Compose resolves it relative to the docker-compose.yml file.
External volumes are created outside of Compose and must already exist:
volumes:
shared-data:
external: true
name: my-shared-volume
This prevents docker compose down -v from deleting the volume. Useful for shared data between multiple Compose stacks.
volumes:
nfs-data:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.5,rw,nfsvers=4.1
device: ":/exports/data"
This creates an NFS mount managed as a Docker volume. The driver_opts are passed directly to the mount syscall.
services:
app:
image: my-app
tmpfs:
- /run/secrets:size=64m,mode=0750
- /tmp:size=256m
Tip: Use the Docker Run to Compose Converter to translate
docker runcommands with-vflags into Compose YAML automatically.
The default local driver stores data on the Docker host's filesystem. For multi-host and cloud setups, volume drivers connect Docker to external storage systems.
The local driver supports NFS, CIFS, and other filesystem types via driver_opts:
# Create an NFS volume
docker volume create \
--driver local \
--opt type=nfs \
--opt o=addr=10.0.0.5,rw,nfsvers=4.1 \
--opt device=:/exports/data \
nfs-share
# Create a CIFS/SMB volume
docker volume create \
--driver local \
--opt type=cifs \
--opt device=//10.0.0.5/share \
--opt o=username=user,password=pass,vers=3.0 \
smb-share
| Driver | Storage Backend | Use Case |
|---|---|---|
| local (NFS opts) | NFS, CIFS, any mount type | On-prem shared storage |
| REX-Ray | AWS EBS, EFS, S3; GCE PD; Azure Disk | Multi-cloud block/file storage |
| Azure File Storage | Azure Files (SMB) | Azure-native file shares |
| Portworx | Distributed block storage | Multi-host stateful workloads |
| GlusterFS | Distributed file storage | On-prem scale-out storage |
For AWS EFS with the local driver:
# Mount EFS using NFS (no extra driver needed)
docker volume create \
--driver local \
--opt type=nfs \
--opt o=addr=fs-0123456789.efs.us-east-1.amazonaws.com,nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2 \
--opt device=:/ \
efs-volume
Most cloud setups use the local driver with NFS mount options rather than installing third-party drivers. This keeps the Docker installation simple and avoids driver compatibility issues.
Volumes don't have a built-in backup command. The standard approach is to mount the volume into a temporary container and use tar to create an archive.
# Backup postgres-data volume to a tar.gz file
docker run --rm \
-v postgres-data:/source:ro \
-v $(pwd):/backup \
alpine:3.19 \
tar czf /backup/postgres-data-backup.tar.gz -C /source .
This command:
postgres-data at /source (read-only)/backup# Create the target volume
docker volume create postgres-data-restored
# Restore from backup
docker run --rm \
-v postgres-data-restored:/target \
-v $(pwd):/backup:ro \
alpine:3.19 \
tar xzf /backup/postgres-data-backup.tar.gz -C /target
# Copy data from one volume to another
docker run --rm \
-v source-volume:/from:ro \
-v target-volume:/to \
alpine:3.19 \
sh -c "cd /from && cp -a . /to/"
For migrating volumes between Docker hosts, combine the backup method with rsync or scp:
# On source host: backup the volume
docker run --rm \
-v mydata:/source:ro \
-v /tmp:/backup \
alpine:3.19 \
tar czf /backup/mydata.tar.gz -C /source .
# Transfer to target host
rsync -avz -e "ssh -p 22" /tmp/mydata.tar.gz user@target-host:/tmp/
# On target host: restore
docker volume create mydata
docker run --rm \
-v mydata:/target \
-v /tmp:/backup:ro \
alpine:3.19 \
tar xzf /backup/mydata.tar.gz -C /target
Tip: Use the Rsync Command Builder to construct the rsync command with SSH settings, compression, and progress flags.
Permission issues are the most common volume-related problem in Docker. Understanding how UIDs map between host and container is essential.
Docker containers do not use usernames for file permissions — they use numeric UIDs and GIDs. A file owned by UID 1000 on the host is owned by UID 1000 inside the container, regardless of what username that UID maps to.
# Host: user "deploy" is UID 1000
id deploy
# uid=1000(deploy) gid=1000(deploy)
# Container: UID 1000 might be "node" or "appuser" or nobody
docker run --rm node:20-alpine id
# uid=1000(node) gid=1000(node)
# Same UID, different names — permissions work
Problems occur when the container process runs as a different UID than the volume's file owner:
# Postgres runs as UID 999
docker run --rm postgres:16 id
# uid=999(postgres) gid=999(postgres)
# Host directory owned by UID 1000 → permission denied for postgres
Use an init container to set correct permissions before the main process starts:
services:
init-permissions:
image: alpine:3.19
volumes:
- app-data:/data
command: ["sh", "-c", "chown -R 1000:1000 /data && chmod -R 755 /data"]
# Runs as root to fix permissions, then exits
app:
image: my-app
user: "1000:1000"
volumes:
- app-data:/data
depends_on:
init-permissions:
condition: service_completed_successfully
volumes:
app-data:
Running containers with --user affects volume permissions:
# Named volume: Docker sets ownership to match the container's user
docker run -d --user 1000:1000 -v mydata:/app/data my-app
# /app/data inside container is owned by 1000:1000
# Bind mount: host permissions are unchanged
docker run -d --user 1000:1000 -v /host/path:/app/data my-app
# /app/data permissions match /host/path — if host UID differs, permission denied
Key difference: Named volumes inherit ownership from the container image's directory on first mount. Bind mounts always reflect the host filesystem permissions.
Volumes accumulate over time. Containers get removed, but their volumes linger. Left unchecked, orphan volumes can consume significant disk space.
docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 15 5 4.2GB 2.8GB (66%)
Containers 8 3 120MB 85MB (70%)
Local Volumes 12 4 8.5GB 6.1GB (71%)
Build Cache 0 0 0B 0B
The Local Volumes row shows 12 total volumes, but only 4 are active (mounted to running containers). The other 8 are orphans consuming 6.1 GB.
docker system df -v
This lists every volume with its size and whether it's in use. For a quick list of orphan volumes:
# List volumes not used by any container
docker volume ls -f dangling=true
DRIVER VOLUME NAME
local 3f8a2b1c...
local old-postgres-data
local temp-build-cache
# Remove all unused volumes (interactive confirmation)
docker volume prune
# Remove all unused volumes without confirmation
docker volume prune -f
# Nuclear option: remove everything unused (images, containers, networks, volumes)
docker system prune --volumes -f
Warning: docker volume prune removes all volumes not currently mounted to a running container. If you stopped a database container for maintenance, its volume is considered "unused" and will be pruned. Always verify with docker volume ls -f dangling=true before pruning.
Add volume cleanup to your maintenance routine:
# Cron job: prune dangling volumes weekly
0 3 * * 0 docker volume prune -f >> /var/log/docker-cleanup.log 2>&1
For production systems, consider labeling important volumes and using --filter to protect them:
# Create labeled volume
docker volume create --label keep=true important-data
# Prune only unlabeled volumes (custom script)
docker volume ls -f dangling=true --format '{{.Name}}' | while read vol; do
labels=$(docker volume inspect --format '{{.Labels}}' "$vol")
if echo "$labels" | grep -qv "keep:true"; then
docker volume rm "$vol"
fi
done
Docker volumes are the foundation of persistent storage in containers. The three mount types serve different purposes:
Key practices for production:
docker run --rm -v + tar patterndocker compose down -vFor hands-on practice:
docker run and docker volume commands with the right flags-v flags to Compose volume declarations