Contents
Docker Volumes Tutorial: Persist Data with Volumes and Bind Mounts
Files written inside a container are stored in its writable layer. When you remove the container, its data goes with it. Data that needs to persist, such as a database or user-uploaded files, must be stored outside the container. Docker provides three mount types:
| Type | Where data is stored | Best for |
|---|---|---|
| Volume | A Docker-managed location | Databases and persistent production data |
| Bind mount | A folder you specify on the host | Syncing code during development and reading host configuration files |
| tmpfs | Memory | Temporary data that you do not want to write to disk |
Volume: remove the container, keep the data
Verify this with PostgreSQL. Start a database and store its data in a volume named pgdata:
docker run -d --name db -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql postgres:18
-v pgdata:/var/lib/postgresql means: mount the volume named pgdata at /var/lib/postgresql inside the container. If the volume does not exist, Docker creates it automatically.
Create a table, insert a row, then remove the container and start a brand-new container with the same volume:
$ docker run -d --name db -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql postgres:18
119ed37ba5c6dc12a773a32d4ef296523529552f94a2ab1ee56113c1b990d7ae
$ docker ps --filter name=db
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
119ed37ba5c6 postgres:18 "docker-entrypoint.s…" 8 seconds ago Up 8 seconds 5432/tcp db
$ docker exec db psql -U postgres -c "CREATE TABLE notes (id serial, body text); INSERT INTO notes (body) VALUES ('hello volume');"
CREATE TABLE
INSERT 0 1
$ docker exec db psql -U postgres -c "SELECT * FROM notes;"
id | body
----+--------------
1 | hello volume
(1 row)
$ docker rm -f db
db
$ docker run -d --name db -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql postgres:18
d94fb2966b3dc5790239b4a48d180aedd193f6fc2c846d6878a7c5fcc450f7cd
$ docker exec db psql -U postgres -c "SELECT * FROM notes;"
id | body
----+--------------
1 | hello volume
(1 row)
The new container can read the data written by the old one. The data lives in the volume, independent of the container's lifecycle.
PostgreSQL 18 data directory
Many older tutorials use
-v pgdata:/var/lib/postgresql/data. That was the approach before PostgreSQL 18, and it causespostgres:18to fail at startup:
$ docker run -d --name db -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:18
c1cec1074a4d440c1f6d106d994171d290cd8ea9da3266d798c0c81662bd8281
$ docker ps -a --filter name=db
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c1cec1074a4d postgres:18 "docker-entrypoint.s…" 8 seconds ago Exited (1) 7 seconds ago db
$ docker logs db
Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" (specifically, using
major-version-specific directory names). This better reflects how
PostgreSQL itself works, and how upgrades are to be performed.
See also https://github.com/docker-library/postgres/pull/1259
Counter to that, there appears to be PostgreSQL data in:
/var/lib/postgresql/data (unused mount/volume)
This is usually the result of upgrading the Docker image without
upgrading the underlying database using "pg_upgrade" (which requires both
versions).
The suggested container configuration for 18+ is to place a single mount
at /var/lib/postgresql which will then place PostgreSQL data in a
subdirectory, allowing usage of "pg_upgrade --link" without mount point
boundary issues.
See https://github.com/docker-library/postgres/issues/37 for a (long)
discussion around this process, and suggestions for how to do so.
$ docker rm -f db
db
$ docker volume rm pgdata
pgdata
The official PostgreSQL 18 image now stores data in a major-version-specific subdirectory such as /var/lib/postgresql/18/docker to make future upgrades with pg_upgrade easier. The new approach is to mount the parent directory /var/lib/postgresql:
$ docker volume ls
DRIVER VOLUME NAME
local 5775c1d90defc3dc23a92cdde10cd62b182d9c774ae43f0a40595fe62131a63c
local pgdata
$ docker volume inspect pgdata
[
{
"CreatedAt": "2026-10-13T00:09:44Z",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
"Name": "pgdata",
"Options": null,
"Scope": "local"
}
]
$ docker exec db sh -c 'echo $PGDATA; ls /var/lib/postgresql'
/var/lib/postgresql/18/docker
18
PGDATAis now/var/lib/postgresql/18/docker.Mountpointindocker volume inspectis the volume's actual location on the Docker host. In Docker Desktop, it is inside Docker's Linux virtual machine, so you cannot access it directly from Windows File Explorer or macOS Finder. Use the backup method below instead.
Managing volumes
docker volume ls # list all volumes
docker volume inspect pgdata # view details
docker volume rm pgdata # remove (only when no container is using it)
docker volume prune # remove unused anonymous volumes
docker volume prune -a # remove named volumes too (be careful!)
docker volume prune -aanddocker system prune --volumesdelete database data. Check before running them, or make a backup first.
Where anonymous volumes come from
Many database images still create a volume even if you do not specify -v, because their Dockerfile contains a VOLUME instruction:
$ docker volume ls
DRIVER VOLUME NAME
$ docker run -d --name anon -e POSTGRES_PASSWORD=secret postgres:18
1db583d1b34d0482cfa458a5fe4a7fc7c1320633bde0339407a0c24fe62ad630
$ docker volume ls
DRIVER VOLUME NAME
local 0bbe1b19b9c02afcc2619b031ceddb1af1b4b75c3068f28eb64642973b3fd6e8
$ docker inspect --format '{{range .Mounts}}{{.Type}} {{.Name}} -> {{.Destination}}{{println}}{{end}}' anon
volume 0bbe1b19b9c02afcc2619b031ceddb1af1b4b75c3068f28eb64642973b3fd6e8 -> /var/lib/postgresql
$ docker rm -f anon
anon
$ docker volume ls
DRIVER VOLUME NAME
local 0bbe1b19b9c02afcc2619b031ceddb1af1b4b75c3068f28eb64642973b3fd6e8
$ docker volume prune -f
Deleted Volumes:
0bbe1b19b9c02afcc2619b031ceddb1af1b4b75c3068f28eb64642973b3fd6e8
Total reclaimed space: 40.49MB
- Just running
docker runadded a volume with a long, random-looking name todocker volume ls: an anonymous volume mounted at/var/lib/postgresql. - After
docker rm -fremoves the container, the anonymous volume is still there, taking up 40 MB. These forgotten anonymous volumes are one reason Docker installations keep growing over time. docker volume pruneremoves unused anonymous volumes. You can also add-vwhen removing a container (docker rm -fv anon) to remove its anonymous volume at the same time.
Use named volumes whenever possible so you can tell what they are for.
Bind mount: mount a host folder into a container
docker run -d --name site -p 8080:80 -v "$(pwd)/site:/usr/share/nginx/html:ro" nginx:1.30
docker run -d --name site -p 8080:80 -v "${PWD}/site:/usr/share/nginx/html:ro" nginx:1.30
docker run -d --name site -p 8080:80 -v "%cd%/site:/usr/share/nginx/html:ro" nginx:1.30

Edit the heading in site/index.html on the host with your editor and save the file. Then refresh the browser:

This is the most common use during development: edit code on the host with your preferred editor, and let the container run it.
Keep these points in mind:
- Host paths must be absolute.
$(pwd)expands to the current directory (use${PWD}in Windows PowerShell). - If you specify a name instead of a path (for example,
site:/data), Docker treats it as a volume name. - On Linux, files written by a container as root are also owned by root on the host. Use
-u $(id -u):$(id -g)to run the container as your user. - Add
:rofor a read-only mount.
-v and --mount when the path does not exist
$ ls -d nothere
ls: nothere: No such file or directory
$ docker run --rm -v "$(pwd)/nothere:/data" alpine:3.23 ls -la /data
total 8
drwxr-xr-x 2 root root 4096 Oct 13 00:09 .
drwxr-xr-x 1 root root 4096 Oct 13 00:09 ..
$ ls -ld nothere
drwxr-xr-x 2 root root 4096 Oct 13 00:09 nothere
$ docker run --rm --mount type=bind,src="$(pwd)/nothere2",dst=/data alpine:3.23 ls /data
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /work/nothere2
Run 'docker run --help' for more information
Windows equivalents (select a tab to switch):
docker run --rm -v "$(pwd)/nothere:/data" alpine:3.23 ls -la /data
docker run --rm --mount type=bind,src="$(pwd)/nothere2",dst=/data alpine:3.23 ls /data
docker run --rm -v "${PWD}/nothere:/data" alpine:3.23 ls -la /data
docker run --rm --mount type=bind,src="${PWD}/nothere2",dst=/data alpine:3.23 ls /data
docker run --rm -v "%cd%/nothere:/data" alpine:3.23 ls -la /data
docker run --rm --mount type=bind,src="%cd%/nothere2",dst=/data alpine:3.23 ls /data
- With
-v, if the host path does not exist, Docker automatically creates an empty folder (owned by root). The container starts successfully, but the mounted folder is empty. This can make you wonder why there are no files even though the mount is in place. - With
--mount type=bind, a path that does not exist causes an error, making typos easier to spot.
The --mount syntax is longer, but clearer:
docker run -d \
--mount type=volume,src=pgdata,dst=/var/lib/postgresql \
--mount type=bind,src="$(pwd)/init.sql",dst=/docker-entrypoint-initdb.d/init.sql,readonly \
-e POSTGRES_PASSWORD=secret postgres:18
docker run -d `
--mount type=volume,src=pgdata,dst=/var/lib/postgresql `
--mount type=bind,src="${PWD}/init.sql",dst=/docker-entrypoint-initdb.d/init.sql,readonly `
-e POSTGRES_PASSWORD=secret postgres:18
docker run -d ^
--mount type=volume,src=pgdata,dst=/var/lib/postgresql ^
--mount type=bind,src="%cd%/init.sql",dst=/docker-entrypoint-initdb.d/init.sql,readonly ^
-e POSTGRES_PASSWORD=secret postgres:18
tmpfs: stored in memory
$ docker run --rm --tmpfs /cache:size=64m alpine:3.23 df -h /cache
Filesystem Size Used Available Use% Mounted on
tmpfs 64.0M 0 64.0M 0% /cache
The data exists only in memory and disappears when the container stops. This is useful for temporary files (Linux containers only).
Back up and restore a volume
Docker manages the volume's location. A portable way to back it up is to start a temporary container and archive the volume as a tar file:
$ docker run --rm -v pgdata:/data:ro -v "$(pwd):/backup" alpine:3.23 tar czf /backup/pgdata.tar.gz -C /data .
$ ls -lh pgdata.tar.gz
-rw-r--r-- 1 root root 4.6M Oct 13 00:10 pgdata.tar.gz
$ docker volume create pgdata-restore
pgdata-restore
$ docker run --rm -v pgdata-restore:/data -v "$(pwd):/backup" alpine:3.23 tar xzf /backup/pgdata.tar.gz -C /data
$ docker run --rm -v pgdata-restore:/data alpine:3.23 ls /data/18/docker
PG_VERSION
base
global
pg_commit_ts
pg_dynshmem
… (output trimmed)
$ rm pgdata.tar.gz
Windows equivalents (select a tab to switch):
docker run --rm -v pgdata:/data:ro -v "$(pwd):/backup" alpine:3.23 tar czf /backup/pgdata.tar.gz -C /data .
docker run --rm -v pgdata-restore:/data -v "$(pwd):/backup" alpine:3.23 tar xzf /backup/pgdata.tar.gz -C /data
docker run --rm -v pgdata:/data:ro -v "${PWD}:/backup" alpine:3.23 tar czf /backup/pgdata.tar.gz -C /data .
docker run --rm -v pgdata-restore:/data -v "${PWD}:/backup" alpine:3.23 tar xzf /backup/pgdata.tar.gz -C /data
docker run --rm -v pgdata:/data:ro -v "%cd%:/backup" alpine:3.23 tar czf /backup/pgdata.tar.gz -C /data .
docker run --rm -v pgdata-restore:/data -v "%cd%:/backup" alpine:3.23 tar xzf /backup/pgdata.tar.gz -C /data
- First command: mount
pgdataread-only at/data, mount the current directory at/backup, and use tar to archive/dataaspgdata.tar.gzin the current directory on the host. - To restore, create a new volume and extract the archive into it the same way. In the restored volume, you can find the PostgreSQL data files under
18/docker.
Archiving files from a database while it is running may produce inconsistent data. The example above backs up the database after it has stopped. For databases, use their own export tools, such as
pg_dumpormysqldump. See Run PostgreSQL with Docker.
Which one should you use?
- Databases and data that must persist long term → Volume
- See code changes immediately during development → Bind mount
- Let a container read a host configuration file → Bind mount (read-only)
- Temporary data that does not need to persist → tmpfs or write directly inside the container
Recap
- Use named volumes for databases and other data that must persist.
- During development, use a bind mount to let the container use code directly from the host.
- If a host path does not exist,
-vcreates an empty folder;--mountreturns an error. - To back up a volume, use a temporary container with both the volume and a host folder mounted, then archive the data with tar.
Exercises
Exercise 1: Create a volume named notes, use one container to write a file, remove the container, then use another container to read the file.
Answer
docker volume create notes
docker run --rm -v notes:/data alpine:3.23 sh -c "echo hello > /data/a.txt"
docker run --rm -v notes:/data alpine:3.23 cat /data/a.txt
The output is hello. See docker volume for volume management commands.
Exercise 2: What is the difference between docker run -v data:/app and docker run -v ./data:/app?
Answer
data has no slash, so Docker treats it as a volume name. ./data is a path, so it is a bind mount of the data folder in the current directory on the host.
FAQ
Where is volume data stored on my computer?
On Linux, it is in /var/lib/docker/volumes/name/_data. Docker Desktop volumes are in its Linux virtual machine, so you cannot see them in Windows File Explorer or Finder. You can view them on the Volumes screen in Docker Desktop.
How do I write a host path with -v on Windows?
In PowerShell, use "${PWD}/site:/app"; in CMD, use "%cd%/site:/app". The commands in this chapter include tabs for all three syntaxes.
Why are bind mount file permissions wrong?
On Linux, files written by a container as root are also owned by root on the host. Use -u $(id -u):$(id -g) to run the container as your user.
Updated October 14, 2026