Dash0 Raises $110M Series B at $1B Valuation

  • 12 min read

How to Preserve Data When a Docker Container Exits"

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:

bash
123
docker ps -a
docker 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:

bash
12345
docker 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:

bash
12345
docker stop postgres && docker rm postgres
docker 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:

bash
12
docker volume ls
docker volume inspect pgdata

Expected output:

text
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:

bash
1
docker 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:

bash
1
docker 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:

bash
1234
docker 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.

bash
123
docker 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:

yaml
12345678910
services:
db:
image: postgres:18
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql
volumes:
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 /:

yaml
123456789
services:
web:
image: nginx
volumes:
- ./html:/usr/share/nginx/html
- type: bind
source: ./config/nginx.conf
target: /etc/nginx/nginx.conf
read_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:

yaml
12345
services:
app:
image: myapp
tmpfs:
- /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:

yaml
12345678910
services:
db:
image: postgres:18
volumes:
- type: volume
source: pgdata
target: /var/lib/postgresql
volumes:
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.