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:

TypeWhere data is storedBest for
VolumeA Docker-managed locationDatabases and persistent production data
Bind mountA folder you specify on the hostSyncing code during development and reading host configuration files
tmpfsMemoryTemporary 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 causes postgres:18 to 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
  • PGDATA is now /var/lib/postgresql/18/docker.
  • Mountpoint in docker volume inspect is 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 -a and docker system prune --volumes delete 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 run added a volume with a long, random-looking name to docker volume ls: an anonymous volume mounted at /var/lib/postgresql.
  • After docker rm -f removes 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 prune removes unused anonymous volumes. You can also add -v when 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
Web page before the change
nginx displays the contents of site/index.html on the host

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

Web page after the change
See the change by refreshing; there is no need to rebuild the image or restart the container

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 :ro for 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 pgdata read-only at /data, mount the current directory at /backup, and use tar to archive /data as pgdata.tar.gz in 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_dump or mysqldump. 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, -v creates an empty folder; --mount returns 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