Guide

Keep data with volumes and bind mounts

A container's files disappear when you remove it. Keep a to-do app's database in a named volume so it survives a new container, bind-mount a directory so you can see the database on your own disk, and learn which one to reach for.

Level
Beginner
Estimated time
20 minutes
Published
Last verified

What you'll learn

  • Explain why data written inside a container is lost when the container is removed
  • Keep data across containers with a named volume, using -v or --mount
  • List, inspect, and remove volumes with docker volume
  • Share a directory on your machine with a container using a bind mount
  • Make a mount read-only with :ro and recognize the error when a container tries to write to it
  • Choose between a volume and a bind mount for a given kind of data

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

You start a to-do app in a container, add a few items, and later replace the container with a fresh one, perhaps to pick up a new image. Your to-dos are gone. Nothing crashed. A container keeps the files it writes inside itself, and removing the container removes them too.

That’s the right default for most files, but not for the data you care about. In this guide, you’ll watch the to-dos disappear, then keep them two ways: in a named volume that Docker manages, and in a bind mount of a directory on your own disk. You’ll make a mount read-only, see what happens when the app tries to write to it anyway, and finish with a rule of thumb for choosing between the two.

Before you start

You can follow this whole guide in your browser. The terminals on this page are simulations: click through them to see what each command does and what it prints.

To run it for real on your own machine, you need:

  • Docker Desktop or Docker Engine. See Get Docker.

  • The sample app’s image. The guide uses the getting-started to-do app, which has no published image, so build it straight from its Git repository. This takes about a minute:

    $ docker build -t todo-app https://github.com/docker/getting-started-todo-app.git

    How that build works is the subject of Write your first Dockerfile. For this guide, you only need the image.

  • curl, to talk to the app’s API. You can also open http://localhost:3000 and add to-dos in your browser instead.

The app stores its to-dos in a SQLite database file at /etc/todos/todo.db inside the container. That one directory, /etc/todos, is what this guide keeps.

Your output will differ from the simulations in the details: container IDs, to-do IDs, and timestamps.

Step 1: Watch the data disappear

Start the app, add a to-do, and list what’s stored:

Host machine

docker run -d -p 3000:3000 --name todo todo-app
cec84effe9586833d7352ad483ffa7b86af5108bbef3e3bf5591b239cb6adbae
curl -s -X POST localhost:3000/api/items -H "Content-Type: application/json" -d '{"name": "Buy milk"}'
{"id":"c0d14300-3d0b-4d12-ba47-dbb2e1913fa5","name":"Buy milk","completed":false}
curl -s localhost:3000/api/items
[{"id":"c0d14300-3d0b-4d12-ba47-dbb2e1913fa5","name":"Buy milk","completed":false}]

Now do what you’d do to upgrade the app: remove the container and start a new one from the same image. docker rm -f stops and removes it in one go.

Host machine

docker rm -f todo
todo
docker run -d -p 3000:3000 --name todo todo-app
8e82f3b847930a4fce1e3b3b019f1d8f038406c9048fd6bb82084b1946e3acd2
curl -s localhost:3000/api/items
[]

An empty list. The new container started from the image, and the image has never contained your to-dos.

Here’s why. An image is read-only. When a container starts, Docker gives it a thin writable layer on top of the image, and every file the container creates or changes lands there, including todo.db. The writable layer belongs to that one container, and docker rm deletes it.

To keep a file, it has to live somewhere outside the writable layer. Docker does that with a mount: you attach storage from outside the container at a path inside it, and reads and writes to that path go to the storage instead.

Where a container's files are stored Inside the todo container, the read-only image layers sit at the bottom and the container's writable layer sits on top of them; the writable layer is deleted with the container. The path /etc/todos is a mount point. Reads and writes there go outside the container, either to a named volume called todo-data that Docker manages, or to a directory called data on your own disk, which is a bind mount. CONTAINER · todo /etc/todos mount point: reads and writes go outside Writable layer everything else it writes; deleted with it Image layers read-only, from todo-app OUTSIDE THE CONTAINER Named volume: todo-data storage that Docker creates and manages -v todo-data:/etc/todos Bind mount: ./data a directory on your own disk -v "$(pwd)/data:/etc/todos"

Docker has two kinds of mount for this: a volume and a bind mount. You’ll use one at a time, at the same path.

Step 2: Keep the data in a named volume

Prefer to watch? This short video explains volumes. It predates this guide, so its example and its Docker Desktop walkthrough differ from the steps here.

A named volume is storage that Docker creates and manages for you. You refer to it by name, and it exists independently of any container.

Remove the container from Step 1, then start a new one with -v todo-data:/etc/todos. The part before the colon is the volume’s name; the part after is where it appears inside the container. If no volume called todo-data exists yet, Docker creates it.

Host machine

docker rm -f todo
todo
docker run -d -p 3000:3000 --name todo -v todo-data:/etc/todos todo-app
41c538ab12c2f72d1a0da53ea336d4cb54e498c48fcf52f81bac9df3bf4e106c
curl -s -X POST localhost:3000/api/items -H "Content-Type: application/json" -d '{"name": "Buy milk"}'
{"id":"a42b08f7-1ead-4961-94f6-f97d9e4dfa17","name":"Buy milk","completed":false}
curl -s -X POST localhost:3000/api/items -H "Content-Type: application/json" -d '{"name": "Walk the dog"}'
{"id":"20d2f4be-4a78-468d-bb01-c61870835854","name":"Walk the dog","completed":false}

Now the same test that lost your data in Step 1: remove the container and start a brand-new one, attaching the same volume.

Host machine

docker rm -f todo
todo
docker run -d -p 3000:3000 --name todo -v todo-data:/etc/todos todo-app
43e225e9a3280319e8f7293f5aca0bfe14f39363e0811a18e30ce323c7f378d3
curl -s localhost:3000/api/items
[{"id":"a42b08f7-1ead-4961-94f6-f97d9e4dfa17","name":"Buy milk","completed":false},{"id":"20d2f4be-4a78-468d-bb01-c61870835854","name":"Walk the dog","completed":false}]

Both to-dos survived, with the same IDs. The old container is gone, but todo.db was never in it: it was in the volume.

Look at the volume

A volume is a Docker object, like a container or an image, with its own commands. List your volumes and inspect this one:

Host machine

docker volume ls
DRIVER    VOLUME NAME
local     todo-data
docker volume inspect todo-data
[
    {
        "CreatedAt": "2026-10-21T20:18:19Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/todo-data/_data",
        "Name": "todo-data",
        "Options": null,
        "Scope": "local"
    }
]
docker volume rm todo-data
Error response from daemon: remove todo-data: volume is in use - [43e225e9a3280319e8f7293f5aca0bfe14f39363e0811a18e30ce323c7f378d3]

Mountpoint is where the volume’s files live, inside Docker’s own storage area. With Docker Desktop, that path is inside Docker’s Linux VM, not on your Mac or Windows file system. Either way, it’s Docker’s to manage: you work with the data through containers, not by editing that directory.

The failed docker volume rm is deliberate. Docker won’t remove a volume that a container is using, and it names the container that holds it. A volume also outlives every container that used it, so it stays on disk until you remove it. You’ll clean it up at the end of this guide.

The same mount, written with --mount

-v is short. Docker also accepts a longer form, --mount, which spells each part out as a key-value pair. These two flags do the same thing:

-v todo-data:/etc/todos
--mount type=volume,src=todo-data,dst=/etc/todos

Replace the container once more, this time with --mount:

Host machine

docker rm -f todo
todo
docker run -d -p 3000:3000 --name todo --mount type=volume,src=todo-data,dst=/etc/todos todo-app
cbd2d2c95686fb9f50d37fc8c789bb17299469a0373341aff7412dd54cb138fe
curl -s localhost:3000/api/items
[{"id":"a42b08f7-1ead-4961-94f6-f97d9e4dfa17","name":"Buy milk","completed":false},{"id":"20d2f4be-4a78-468d-bb01-c61870835854","name":"Walk the dog","completed":false}]

Same volume, same data. The rest of this guide uses -v because it’s shorter to read. The Docker documentation generally prefers --mount, because it’s more explicit and supports every option, some of which -v can’t express. See Volumes for the differences.

Step 3: Share a directory with a bind mount

Prefer to watch? This short video covers bind mounts and the -v and --mount flags. It predates this guide, so its example and its Docker Desktop walkthrough differ from the steps here.

A volume keeps your data safe, but you can’t easily see it: it lives in Docker’s storage area. Sometimes you want the opposite. You want the file on your own disk, where your editor, your backup tool, or a database browser can reach it.

A bind mount attaches a directory from your machine at a path inside the container. It’s the same directory, seen from two places: whatever the container writes there appears on your disk, and whatever you put there appears inside the container.

Replace the container, create a data directory in your current directory, and bind-mount it at /etc/todos. With -v, a source that starts with / is a path on your machine; a plain name, like todo-data, is a volume. $(pwd) expands to the full path of your current directory.

Host machine

docker rm -f todo
todo
mkdir data
docker run -d -p 3000:3000 --name todo -v "$(pwd)/data:/etc/todos" todo-app
aab20c5678ad412a20ae1517f3de257e484cdcb2b971381e572f08996b27ccbd
ls data
todo.db
curl -s -X POST localhost:3000/api/items -H "Content-Type: application/json" -d '{"name": "Buy milk"}'
{"id":"690b8825-5e5a-4334-978c-e3bf2fa10f7d","name":"Buy milk","completed":false}

todo.db is now an ordinary file in data on your machine. The app created it at startup, and every to-do you add is written to it. This is a fresh, empty database: the to-dos in the todo-data volume are still there, but this container isn’t using the volume.

The mount works in the other direction too. Write a file into data on your machine, and look for it from inside the running container:

Host machine

echo 'Back up todo.db before upgrading' > data/notes.txt
docker exec todo ls /etc/todos
notes.txt
todo.db
docker exec todo cat /etc/todos/notes.txt
Back up todo.db before upgrading

There’s no copying involved. The container sees the change the moment you make it, because it’s looking at the same directory. That’s what makes bind mounts the tool for source code and configuration you’re editing.

Remove the container, and the files stay where they are, on your disk:

Host machine

docker rm -f todo
todo
ls data
notes.txt
todo.db

Step 4: Make a mount read-only

The container can change or delete anything in a bind-mounted directory, because it’s your directory. When a container only needs to read files, such as configuration, add :ro to the end of the mount to make it read-only.

To see what read-only means, mount data read-only and try to use the app as usual. Reading works. Adding a to-do does not:

Host machine

docker run -d -p 3000:3000 --name todo -v "$(pwd)/data:/etc/todos:ro" todo-app
c179c7e0580a0071f95933c5137f57077bb33b05c601da86d213e30c18039226
curl -s localhost:3000/api/items
[{"id":"690b8825-5e5a-4334-978c-e3bf2fa10f7d","name":"Buy milk","completed":false}]
curl -s -X POST localhost:3000/api/items -H "Content-Type: application/json" -d '{"name": "Call the vet"}'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Internal Server Error</pre>
</body>
</html>
docker logs todo
Using sqlite database at /etc/todos/todo.db
Listening on port 3000
Error: SQLITE_READONLY: attempt to write a readonly database
docker exec todo touch /etc/todos/test.txt
touch: cannot touch '/etc/todos/test.txt': Read-only file system

That error is the point. The app read todo.db and served the existing to-do, but SQLite couldn’t write to it, and even touch running as root inside the container can’t create a file. The container can’t change what you mounted read-only, whatever it runs as.

A database is the wrong thing to mount read-only, which is why the app broke. Use :ro for files the container should consume but never change: a config file, certificates, or source code that a container only serves. :ro works the same way on a volume.

Step 5: Choose between a volume and a bind mount

Both mounts keep data outside the container. What differs is who owns the data.

Named volume Bind mount
Where the data lives Docker’s storage area, by name A directory you choose on your machine
Who manages it Docker: docker volume ls, inspect, rm You: your file manager, editor, and backups
Starts out Empty, or seeded from the image’s files at that path With whatever is already in your directory
Depends on your machine No: same name, same behavior anywhere Docker runs Yes: the path has to exist on that machine
Reach for it for Data the app owns: databases, uploads, caches you want to keep Files you edit on the host: source code, config

The rule of thumb: if the application owns the data, use a volume. If you own the files and want to edit them on your machine, use a bind mount. Add :ro to either when the container should only read.

For the to-do app, the database belongs in a volume. The bind mount in Step 3 was useful for seeing todo.db, but in practice you’d keep the database in a volume and bind-mount your source code instead, so the container picks up your edits.

Try the rule on some cases:

Pick the best fit for each case:

Data Best fit Why
A PostgreSQL container’s database files, which you never open yourself Named volume The database owns them; a volume keeps them without tying them to a path
Source code you edit while the app runs in a container Bind mount You own the files, and the container needs each change as you save it
An nginx config file the container should read but never change Read-only bind mount You edit it on your machine; :ro stops the container from changing it
Temporary files the app recreates on every start No mount The writable layer is fine for files you’re happy to lose
The to-do database, when you want to open todo.db in a tool on your laptop Bind mount You need the file on your own disk

Clean up

Remove the container, the volume, and the data directory. A volume is never removed with its container, so it needs its own docker volume rm, which works now that nothing is using it:

Host machine

docker rm -f todo
todo
docker volume rm todo-data
todo-data
rm -rf data
docker volume ls
DRIVER    VOLUME NAME

If you have other volumes, docker volume ls lists them too. Don’t reach for docker volume prune to tidy up unless you mean it: it removes every volume that no container is using, including ones you meant to keep.

What you learned

  • A container writes to its own writable layer, and docker rm deletes it, along with anything the app stored there.
  • A mount attaches storage from outside the container at a path inside it, so the data outlives the container.
  • A named volume (-v todo-data:/etc/todos) is storage Docker manages. You list, inspect, and remove it with docker volume, and Docker won’t remove one that’s in use.
  • A bind mount (-v "$(pwd)/data:/etc/todos") shares a directory on your machine. Changes on either side are visible on the other immediately.
  • :ro makes a mount read-only. A write fails with a read-only file system error, even for root in the container.
  • Use a volume for data the application owns, and a bind mount for files you edit on your machine.

Next

A real application usually needs more than one container: the to-do app with its database in a separate MySQL container, each with its own storage. In Run a multi-container app with Compose, you’ll connect them on a network and describe the whole thing, volume included, in one compose.yaml file.

Learning paths

Part of a learning path

This works on its own and is also included in the paths below. Open a path to see the complete route.