A container's writable layer is destroyed when you remove the container, which is why data written inside a running container vanishes after docker rm or docker compose down. Stopping a container with docker stop is a different story. The writable layer survives until you actually remove the container, so a stopped container still holds its data and docker start brings it back as you left it.
For data that needs to outlive any single container instance, you have to write it somewhere outside that writable layer. Docker gives you three mechanisms for that: volumes, bind mounts, and tmpfs mounts. The rest of this article covers how each one works, the syntax for docker run and Docker Compose, and the pitfalls that catch people off guard.
First, understand what "exits" means
If you started a container with docker run (no --rm flag), did some work, and the container exited or you stopped it, your data is still there. Try this:
123docker ps -adocker start <container-id>docker exec <container-id> ls /tmp
You should see your files. The writable layer only goes away when the container itself is removed, which happens with docker rm, with docker run --rm, or when you bring down a Compose stack with docker compose down. To restart a container without losing in-container state, docker start is enough.
But relying on the writable layer for anything you care about is a trap. All it takes is one docker compose down from a teammate, or a CI pipeline that always runs containers with --rm, or a redeploy with a new image version, and your data is gone. For anything stateful (databases, uploads, application state, logs you want to keep), use one of the mount types below.
Named volumes: the default answer
A named volume is storage that Docker creates and manages on the host, independent of any container. It survives container removal, can be reattached to a new container, and behaves the same on Linux, macOS, and Windows.
Create one and attach it to a container in a single command:
12345docker run -d \--name postgres \-e POSTGRES_PASSWORD=secret \-v pgdata:/var/lib/postgresql \postgres:18
The -v pgdata:/var/lib/postgresql syntax tells Docker: if a volume named pgdata exists, mount it; if not, create it. Now write some data, stop and remove the container, then start a fresh one with the same volume:
12345docker stop postgres && docker rm postgresdocker run -d \--name postgres \-v pgdata:/var/lib/postgresql \postgres:18
The new container picks up the existing data. The volume itself only goes away when you explicitly run docker volume rm pgdata or docker volume prune.
You can inspect volumes with the usual subcommands:
12docker volume lsdocker volume inspect pgdata
Expected output:
1234567891011[{"CreatedAt": "2026-06-01T12:00:00Z","Driver": "local","Labels": {},"Mountpoint": "/var/lib/docker/volumes/pgdata/_data","Name": "pgdata","Options": {},"Scope": "local"}]
The Mountpoint is where the data actually lives on the host. You can poke at it directly, but Docker explicitly discourages this because the on-disk layout is undefined and may change between releases. Treat volumes as opaque storage you only touch through containers.
Anonymous volumes: usually a mistake
If you mount a path without giving it a name, Docker creates an anonymous volume:
1docker run -v /app/data myimage
The same thing happens implicitly for any VOLUME instruction in a Dockerfile that isn't explicitly mapped to a named volume at runtime. Anonymous volumes get random IDs like 2f075f6a07ebb4c9d2... and pile up silently in docker volume ls. They're hard to identify, hard to back up, and impossible to reattach because you have no idea which one belongs to which container.
Name your volumes. The only legitimate use for anonymous volumes is short-lived --rm containers where the volume gets cleaned up with the container:
1docker run --rm -v /tmp/scratch myimage
Bind mounts: for development and host integration
A bind mount maps a specific directory on your host into the container. Unlike volumes, the location is fully under your control:
1234docker run -d \--name dev \-v /home/you/project:/app \node:22
Changes on either side are immediately visible on the other, which is why bind mounts are great for development. You can edit code on your host with your normal editor and see changes inside the container without rebuilding.
The tradeoff is that you're responsible for permissions, backups, and portability. A bind mount baked into your docker run command won't work on someone else's machine if the host path doesn't exist. Bind mounts also have a quirk worth knowing: when you mount a host directory over an existing container path, the original container contents at that path are hidden, not merged. Volumes behave differently here. Mounting an empty named volume copies the container's existing files into the volume on first use.
In production, prefer named volumes unless you have a specific reason to expose host paths (sharing source code during development, mounting a config directory, integrating with a host-level backup system).
tmpfs mounts: in-memory, on purpose
A tmpfs mount stores data in the host's RAM rather than on disk. Anything written there is gone when the container stops. That's the opposite of what this article is about, but it's worth knowing because tmpfs solves a related problem.
Use tmpfs for data you specifically don't want persisted: secrets that should never hit disk, scratch space for high-throughput temporary writes, session state that should die with the container.
123docker run -d \--tmpfs /run/secrets:size=64m,mode=0700 \myimage
tmpfs is Linux-only. On macOS and Windows the flag is accepted but mapped through the Docker VM, which negates some of the performance benefit. Don't use tmpfs for anything you can't afford to lose on container restart, including unexpected ones.
Docker Compose: declaring volumes properly
Compose makes all three mount types more readable, and it's where most production volume definitions actually live. A minimal example with a named volume:
12345678910services:db:image: postgres:18environment:POSTGRES_PASSWORD: secretvolumes:- pgdata:/var/lib/postgresqlvolumes:pgdata:
The top-level volumes: key declares the volume; the service-level volumes: mounts it. When you run docker compose up, Compose creates the volume if it doesn't exist and reuses it on every subsequent run.
For a bind mount, use a path that starts with ./ or /:
123456789services:web:image: nginxvolumes:- ./html:/usr/share/nginx/html- type: bindsource: ./config/nginx.conftarget: /etc/nginx/nginx.confread_only: true
The long syntax (type: bind) is more explicit and supports flags like read_only. The short syntax is fine for simple cases, but watch the prefix: if the source path doesn't start with ./ or /, Compose treats it as a named volume reference, which fails silently if no such volume is declared.
For tmpfs:
12345services:app:image: myapptmpfs:- /run/secrets:size=64m,mode=0700
The full long-form syntax works for all three types and is the recommended style for anything beyond trivial cases:
12345678910services:db:image: postgres:18volumes:- type: volumesource: pgdatatarget: /var/lib/postgresqlvolumes:pgdata:
Common pitfalls
The biggest foot-gun is docker compose down -v. Plain down keeps your named volumes around because that's almost always what you want. Adding -v wipes them. Useful for a clean rebuild, catastrophic against a production database, and easy to mistype.
Next is the VOLUME instruction in a Dockerfile. If your image declares VOLUME /data, every container started from that image gets a fresh anonymous volume unless you explicitly mount a named volume at the same path. This is one reason database images quietly accumulate orphan volumes over time. Either skip VOLUME in your Dockerfile or always mount a named volume to override it.
There's also an asymmetry between volumes and bind mounts that catches people setting up Postgres for the first time. When you mount an empty named volume into a container, Docker copies the image's existing files into the volume on first use. Mounting a bind mount over the same path hides the original files instead. Official database images rely on the copy-on-first-mount behavior to seed /var/lib/postgresql, which means pointing a bind mount at an empty host directory leaves you with a broken database. Use a named volume.
Finally, permissions. A volume first written by a container running as root will be owned by root on the host. If you then run a container that wants to write as a non-root user, you get permission denied. Either align UIDs between your image and the volume, fix permissions in an init container, or use the user: directive consistently across every service that shares a volume. Production databases tend to be the place this bites first.
Final thoughts
Persistent volumes are the boundary between an ephemeral container fleet and the durable state your application depends on. Once they're set up correctly, the next question is whether you can see what's happening inside them: which container is writing how much data, when disk usage started climbing, whether a backup actually completed.
Dash0's infrastructure monitoring surfaces container and volume metrics alongside real-time logs and distributed traces, so you can correlate a volume filling up with the application behavior that caused it. Everything is OpenTelemetry-native, so your data isn't locked into a proprietary format.
Start a free trial to see your container infrastructure, volumes, and traces in one place. No credit card required.