There’s a distinction I didn’t fully appreciate when I was new to Docker: sharing data between containers and sharing data between your host and a container are related but different problems, solved with slightly different tools and different intent. The first is about coordination between processes. The second is about persistence and access — getting data that lives on your machine into a container, and making sure data a container generates survives even if the container is destroyed. This article focuses on that host-to-container relationship, and on setting up storage that actually persists.
Why Persistent Storage Matters
By default, everything a container writes lives in its writable layer, which is deleted the moment you run docker rm. For a stateless web server, that’s fine. For a database, a CMS with uploaded media, or a build pipeline that needs to cache dependencies across runs, it’s a disaster waiting to happen. I’ve seen people lose a day’s worth of database writes because they ran docker compose down without realizing the data wasn’t actually persisted anywhere outside the container.
Docker solves this with two host-facing mechanisms:
- Bind mounts — expose an existing host directory directly into the container
- Named volumes — Docker-managed directories that persist independently of any single container, physically stored on the host under
/var/lib/docker/volumes/
Both achieve persistence. The difference is where the data lives conceptually and who manages the path.
Bind Mounts: Host Path Straight Into the Container
A bind mount is the simplest mental model: you’re pointing a container at a specific folder that already exists on your host.
mkdir -p ~/docker-data/app-config
echo "debug=true" > ~/docker-data/app-config/settings.conf
docker run -d --name myapp \
-v ~/docker-data/app-config:/etc/myapp \
busybox sh -c "cat /etc/myapp/settings.conf && sleep 3600"
Check the logs:
docker logs myapp
Expected output:
debug=true
Now edit the file directly on the host:
echo "debug=false" > ~/docker-data/app-config/settings.conf
docker exec myapp cat /etc/myapp/settings.conf
Expected output:
debug=false
That instant reflection is the main appeal of bind mounts — no rebuild, no copy step, the container sees the host filesystem live. This is why bind mounts are the standard choice for local development (mounting your source code directory into a container running your dev server).
Using the more explicit --mount syntax:
docker run -d --name myapp \
--mount type=bind,source=/home/$(whoami)/docker-data/app-config,target=/etc/myapp \
busybox sh -c "sleep 3600"
Note that --mount requires an absolute source path — ~ expansion and relative paths aren’t accepted the way -v sometimes tolerates them, which is one more reason --mount reduces silent mistakes.
Named Volumes: Docker-Managed Persistent Storage
Where bind mounts require you to manage the host path yourself, named volumes let Docker manage the location. You just refer to them by name.
docker volume create app-data
docker run -d --name db-test -v app-data:/var/lib/data busybox \
sh -c "date > /var/lib/data/created_at && sleep 3600"
docker rm -f db-test
The container is gone, but the volume — and its data — is not:
docker run --rm -v app-data:/var/lib/data busybox cat /var/lib/data/created_at
Expected output:
Wed Jul 29 10:40:22 UTC 2026
This is the core guarantee of persistent storage in Docker: as long as the volume exists, the data survives container recreation, image upgrades, and docker rm. You only lose it if you explicitly run docker volume rm (or docker compose down -v, or docker system prune --volumes).
Where Docker Actually Stores Volume Data
On a standard Linux install, named volumes live at:
/var/lib/docker/volumes/<volume-name>/_data
You can confirm this:
docker volume inspect app-data --format '{{ .Mountpoint }}'
Expected output:
/var/lib/docker/volumes/app-data/_data
You can technically read files there directly with sudo, but I’d strongly discourage editing them outside of Docker — permissions, SELinux contexts, and any volume driver logic get bypassed, which can lead to subtle corruption.
The VOLUME Instruction in a Dockerfile
You can declare a volume mount point inside an image itself:
FROM alpine:3.20
RUN mkdir /data
VOLUME /data
CMD ["sh", "-c", "sleep 3600"]
Build and run it:
docker build -t volume-demo .
docker run -d --name vd volume-demo
docker inspect vd --format '{{ json .Mounts }}'
Expected output (formatted for readability):
[
{
"Type": "volume",
"Name": "8f2a1c...",
"Destination": "/data",
"Driver": "local",
"Mode": "",
"RW": true
}
]
Notice Docker auto-created an anonymous volume because none was specified at docker run time. This is a common gotcha: if you forget to bind an explicit named volume to a path declared with VOLUME in the Dockerfile, Docker silently creates a random one, and these tend to pile up over time as containers get recreated. I always check docker volume ls periodically on hosts running images with VOLUME instructions.
docker volume ls
Expected output:
DRIVER VOLUME NAME
local 8f2a1c9e4b7d3f...
local app-data
Persistent Storage for a Real Database Container
Here’s a pattern I use constantly — PostgreSQL with its data directory backed by a named volume, so upgrading or recreating the container never touches actual data:
docker volume create pg-data
docker run -d --name postgres-db \
-e POSTGRES_PASSWORD=changeme \
-v pg-data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
Verify persistence by writing data, removing the container, and recreating it:
docker exec -it postgres-db psql -U postgres -c "CREATE TABLE test (id serial);"
docker rm -f postgres-db
docker run -d --name postgres-db \
-e POSTGRES_PASSWORD=changeme \
-v pg-data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
docker exec -it postgres-db psql -U postgres -c "\dt"
Expected output:
List of relations
Schema | Name | Type | Owner
--------+------+-------+----------
public | test | table | postgres
(1 row)
The table survived a full container recreation because the volume, not the container, holds the actual data files.
Docker Compose: Persistent Storage Setup
version: "3.9"
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: changeme
volumes:
- pg-data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pg-data:
driver: local
docker compose up -d
docker compose down # data survives
docker compose down -v # data is deleted
That last line is worth memorizing — it’s the single most common way people accidentally wipe production-adjacent data during a routine cleanup.
Volume Drivers: Beyond Local Disk
The default local driver stores data on the host’s filesystem. For multi-host setups (Docker Swarm, or shared storage across machines), you can specify alternate drivers, such as an NFS-backed volume:
docker volume create --driver local \
--opt type=nfs \
--opt o=addr=192.168.1.50,rw \
--opt device=:/exported/path \
nfs-data
This lets multiple Docker hosts mount the same underlying storage, which is what makes it possible to run stateful services across a Swarm cluster or migrate a container between hosts without losing data.
Internal Working: overlay2 vs Volume Mounts
It’s worth being precise about why volumes are fast and safe compared to writing large amounts of data into the container’s writable layer.
The overlay2 storage driver implements copy-on-write semantics across image layers. Every write to a file that exists in a lower layer triggers a full-file copy-up before modification — this is efficient for small config tweaks, but genuinely bad for large, frequently-modified files like a database’s data files, because every write can trigger expensive copy operations and bloat the writable layer.
A volume, in contrast, is bind-mounted directly — no union filesystem, no copy-on-write, no layering. I/O goes straight to the host filesystem (or whatever backend the volume driver uses), which is why the Postgres official image, and most database images generally, explicitly document that their data directories should be mounted as volumes, not left inside the default writable layer.
docker run --rm alpine sh -c "df -h / && mount | grep overlay"
This shows the container’s root is an overlay mount, while a volume mount at a separate path shows up as a distinct bind entry in mount output — a good way to visually confirm the difference on a running container.
Backing Up and Restoring a Host Volume
A quick way to snapshot a named volume to a tarball on the host (a full walkthrough of database-specific backups is in the companion article on database backups):
docker run --rm \
-v pg-data:/data \
-v $(pwd):/backup \
busybox tar czf /backup/pg-data-backup.tar.gz -C /data .
Restoring into a fresh volume:
docker volume create pg-data-restored
docker run --rm \
-v pg-data-restored:/data \
-v $(pwd):/backup \
busybox tar xzf /backup/pg-data-backup.tar.gz -C /data
Security: SELinux, Permissions, and Read-Only Mounts
On SELinux-enforcing hosts (common on RHEL/Fedora/CentOS), bind mounts need a relabel flag or the container process will get Permission denied even when Unix permissions look fine:
docker run -v ~/docker-data/app-config:/etc/myapp:Z busybox cat /etc/myapp/settings.conf
:Zrelabels the content for exclusive use by this container.:z(lowercase) relabels for shared use by multiple containers.
Other practical security habits:
- Mount configuration or secrets as
:rounless the container genuinely needs to write to them. - Avoid bind-mounting broad host directories like your entire home folder or
/— scope mounts to the narrowest directory the container actually needs. - Set explicit UID/GID on volume contents (
chowninside an init container or entrypoint script) rather than relying on root-owned defaults, especially if the main container process drops privileges.
Kubernetes: The Same Idea, Different Objects
On Kubernetes, “host to pod” persistent storage maps to PersistentVolume and PersistentVolumeClaim, with hostPath as the direct (and generally discouraged outside single-node clusters) equivalent of a Docker bind mount:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pg-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /mnt/data/postgres
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pg-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_PASSWORD
value: changeme
volumeMounts:
- name: pg-storage
mountPath: /var/lib/postgresql/data
volumes:
- name: pg-storage
persistentVolumeClaim:
claimName: pg-pvc
In production Kubernetes clusters, you’d typically use a StorageClass backed by your cloud provider’s block storage (EBS, Persistent Disk, Azure Disk) instead of hostPath, which ties data to a specific node.
Best Practices
- Use named volumes for anything you’d be upset to lose — databases, uploaded media, generated certificates.
- Use bind mounts for source code during development and for injecting configuration files that you want to edit from the host.
- Always check what
VOLUMEinstructions exist in third-party images before running them, so you don’t end up with orphaned anonymous volumes. - Never rely on the container’s writable layer for anything you need to keep — treat it as scratch space.
- Automate periodic volume backups (cron job or scheduled CI pipeline running the tar-based snapshot shown above) rather than relying on manual exports.
Troubleshooting
Data disappeared after an image update Check whether the previous container used an anonymous volume instead of a named one — anonymous volumes aren’t automatically reused across docker run invocations unless explicitly referenced by ID.
“Permission denied” on a bind-mounted directory On SELinux hosts, add :Z or :z. On any host, check that the UID the container process runs as has read/write access to the host directory’s actual owner and mode:
ls -la ~/docker-data/app-config
docker exec myapp id
Disk filling up unexpectedly Anonymous volumes and dangling volumes are a very common cause:
docker volume ls -f dangling=true
docker volume prune
Run prune carefully — always confirm nothing important is dangling before removing.
Monitoring Storage Usage
docker system df -v
du -sh /var/lib/docker/volumes/*/_data
For ongoing monitoring in production, tools like cAdvisor, node_exporter (for host disk metrics), and Prometheus give you alerting on volume disk usage before it becomes an outage.
Summary
Persistent storage in Docker comes down to picking the right mechanism for the right job: bind mounts for direct, live access to a specific host path (great for development and configuration injection), and named volumes for durable, Docker-managed storage that survives container recreation (the right default for databases and any stateful service). Understanding that volumes bypass the overlay filesystem entirely explains both their performance advantage and why official database images insist on them. Get the volume setup right from day one, and container recreation — upgrades, restarts, redeploys — stops being a risky event and becomes routine.
References
- Docker Docs — Manage data in Docker
- Docker Docs — Volumes
- Docker Docs — Bind mounts
- Docker Docs — Dockerfile reference: VOLUME
- Kubernetes Docs — Persistent Volumes
- Kubernetes Docs — Storage Classes
- CNCF — Cloud Native Storage Landscape