SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-10-28

Docker Compose volumes: bind mount or named volume?

Learn when to use bind mounts or named volumes for config and data, avoid file permission traps, and inspect, back up, or migrate Docker Compose storage.

Bind mount or named volume: di short answer

Docker Compose volumes get two kinds, and di choice na who own the files. Use bind mount for files wey you dey write and read by yourself, like config, templates, and static sites. Use named volume for data wey the application own, like database files, search indexes, and uploaded media. Bind mount point to path for the host wey you fit open with editor. Named volume na storage wey Docker create and track for you, and you access am through Docker.

Both dey show under the same volumes: key inside service, na why people dey mix dem up. Di difference dey for the left side of the colon. Left side wey start with . or / na host path, so na bind mount. Anything else na name, so na named volume, and you must also declare that name inside the top-level volumes: block.

Compose file inside syntax dem

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: changeme
    volumes:
      - pgdata:/var/lib/postgresql/data
  web:
    image: nginx:1.27
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./site:/usr/share/nginx/html:ro

volumes:
  pgdata:

pgdata:/var/lib/postgresql/data na named volume. ./nginx.conf:/etc/nginx/nginx.conf na bind mount, and :ro mount am read only. Na the correct default for config wey container no suppose ever rewrite. If you forget the top-level volumes: entry, Compose go stop with service "db" refers to undefined volume pgdata.

Bring am up and list wetin Docker create:

docker compose up -d
docker volume ls

The volume name no be pgdata. E be <project>_pgdata, because project name by default na the name of the directory wey hold the compose file. Directory wey dem call myapp go give myapp_pgdata. This one matter because if you rename the directory, you go get new empty volume, and the application go look like say e lose im data. E no lose am: the old volume still dey listed by docker volume ls. Pin the name with name: for the compose file, or set COMPOSE_PROJECT_NAME, if the directory fit move. Settings like this one belong with your other Compose environment files and secrets.

Why permission errors mostly dey affect bind mounts

Na dis be the biggest practical difference, and e mostly come from one rule: named volume wey empty for first use dey get seeded from the image, but bind mount no dey get seeded.

When Docker mount empty named volume over directory wey already get content for image, e copy that content enter volume, with the ownership and modes wey image set. The official Postgres image releases /var/lib/postgresql/data wey dey owned by its own postgres user, so volume go come out owned by that same numeric id, and database go start.

That seeding na the only head start named volume get. Docker manage where volume dey, but containers read and write am with ordinary filesystem operations, so ownership and modes dey enforced just as dem dey for bind mount. Named volume mounted for path wey image no get, or one wey different image or user fill first, no get ownership from image, and non-root process fit still get permission denied there. The id check below dey work for both kinds.

Bind mount dey do the opposite. Anything wey dey for host na wetin container go see, including ownership, and image content for that path go hide. If host directory no exist, Docker daemon go create am, and daemon dey run as root, so you go get directory wey root:root own. Container process wey dey run as non-root user then no fit write to am:

PermissionError: [Errno 13] Permission denied: '/data/app.db'

The fix na to make the numbers agree. Ownership across bind mount dey compare with numeric user id, no be name, because container get its own /etc/passwd. User wey dem call app inside container mean nothing for host. Uid 1000 mean uid 1000 for both sides, unless daemon dey run with user-namespace remapping (userns-remap, set for /etc/docker/daemon.json or for dockerd command line). Remapping dey shift every container id enter subordinate range for host. For Docker example range wey start for 231072, container uid 0 na host uid 231072, so container uid 1000 na host uid 232072.

id -u
mkdir -p ./data
docker compose exec web id
sudo chown -R 1000:1000 ./data

docker compose exec web id dey print the uid wey container process actually dey run as. Without remapping, match host directory with that number, or pin container to your number with user: "1000:1000" for the service. When remapping dey on, user: no fit pin container to your host number, because the id wey you set go shift too: user: "1000:1000" still go land for host uid 232072 for that example range. Chown host directory to the mapped host id instead (your own range dey for /etc/subuid and /etc/subgid). Docker documentation name only --userns=host on docker container create, run or exec as the way to switch remapping off for one container. Pinning user: cleaner for application wey you write yourself. Chowning host directory safer for image wey you no write, because some images start entrypoint as root, drop privileges, and expect specific ownership underneath.

Two more traps dey worth knowing. For Fedora, RHEL and other systems wey get SELinux (security-enhanced Linux) enforcing, bind mount go get denied until dem relabel am, so add :z for path wey containers share or :Z for path wey only one container suppose use, written as - ./data:/data:Z. And bind mount of one file, instead of directory, go break when editor replace file instead of writing in place, because mount dey follow the original inode. Container go continue see the old content until you restart am. Mount the parent directory when dem dey edit the file often.

Performance: where the difference dey real

For Linux server, both types dey pass through the same kernel path, so the throughput difference small enough say you no suppose choose based on that. Named volumes wey dey use the default local driver dey live for the same filesystem as the rest of Docker, under /var/lib/docker/volumes/, while bind mount dey live anywhere wey you point am.

The difference dey show for Docker Desktop on macOS and Windows, where containers dey run inside virtual machine. Bind mount for there dey cross from host filesystem enter that virtual machine through file sharing layer. Workloads wey dey do plenty small file operations, like Node.js dependency tree or PHP framework cache, go slow down noticeably. Named volumes dey stay inside the virtual machine and no dey pay that cost. Na why plenty development compose files dey bind-mount the source directory but declare named volume over node_modules.

The other real difference na where the bytes dey land. Bind mount to /mnt/backup puts data for that disk. Named volume lands on any filesystem wey dey hold /var/lib/docker, and for VPS, na usually root disk. Database wey dey grow inside named volume fit fill the same disk wey your system logs dey use. Check am before e turn to incident:

docker system df -v
df -h /var/lib/docker

docker system df -v lists every volume with its size, and marks the ones no container refers to any more.

Yenà named volume

Named volume no be black box. Ask Docker where e dey:

docker volume inspect myapp_pgdata

The Mountpoint field dey show real host path, normally /var/lib/docker/volumes/myapp_pgdata/_data. You fit read am with sudo ls, and this one useful for quick check. No treat am as place to edit files. If you write there as root, you go recreate the ownership problem wey we describe above. The path na detail of local driver, and other volume drivers no share am.

The safe way to look inside na to use throwaway container wey mount the volume:

docker run --rm -v myapp_pgdata:/vol alpine ls -la /vol

This one work for any driver, e see the same permissions wey the real container see, and e leave nothing behind because of --rm.

Backing up each kind

Bind mount na ordinary directory, so any file-level backup tool already fit handle am. Point the backup to the host path and you don finish. Named volume need one extra step, because the tool need enter inside am. Mount the volume and one host directory inside the same short-lived container, then write an archive:

docker run --rm \
  -v myapp_pgdata:/data:ro \
  -v "$PWD":/backup \
  alpine tar czf /backup/pgdata.tar.gz -C /data .

Restore am by reversing the process into a fresh volume:

docker volume create myapp_pgdata_restored
docker run --rm \
  -v myapp_pgdata_restored:/data \
  -v "$PWD":/backup \
  alpine tar xzf /backup/pgdata.tar.gz -C /data

tar dey preserve numeric ownership when e run as root inside the container. Na this one keep the restored volume usable by the application.

One warning apply to both kinds. If you copy database files while the database dey run, you go get archive of data wey still dey change, and e fit restore into corrupt state. Stop the service first, or dump through the database own tool, as shown for docker compose exec -T db pg_dump -U postgres appdb > appdb.sql. This one produce plain file wey you fit include inside normal encrypted restic backup routine together with your compose files.

Move bind mount go named volume

The move na copy, e no be rename, and e dey take about one minute.

docker compose down
docker volume create myapp_pgdata
docker run --rm \
  -v "$PWD/data":/from \
  -v myapp_pgdata:/to \
  alpine sh -c 'cp -a /from/. /to/'

cp -a dey keep ownership, modes, and timestamps, so the container user wey fit read the old directory still fit read the new volume. Then change the service make e use pgdata:/var/lib/postgresql/data, add pgdata to the top-level volumes: block, run docker compose up -d, and read the application logs before you delete the old directory. To go the other way, use the same command but swap /from and /to.

Make you remember one thing while you dey test. docker compose down no dey touch named volumes, but docker compose down -v dey delete every named volume wey the project declare, and you no fit undo am. Bind mount dey survive both, because Docker never own that directory. If the lifecycle commands still new to you, the Docker Compose basics guide for VPS go explain how dem work.

Choosing, service by service

Ask who dey write the file. Config wey you edit for text editor and commit to git belong for bind mount, mounted :ro, because you want make e visible and versioned. Application state wey you never open by hand belong for named volume, because Docker go seed the permissions from the image and the data no depend on host path.

The mixed case na media. Application dey write photo library, but you still dey manage am, and e often big reach say you go want specific disk. Bind mount am to path for that disk, then set the ownership deliberately one time. Na this pattern most self-hosted stacks dey use: named volumes for databases and caches, bind mounts for config and the large directory wey matter to you. Support desk like Chatwoot wey dey run for VPS land exactly there, with Postgres for named volume and uploaded attachments for path wey you fit point backup to.

FAQ

Wetin be the difference between bind mount and named volume?

Bind mount dey map path for host enter container, so both sides dey see the same directory and you fit edit am with normal tools. Named volume na storage wey Docker create and manage, referred to by name and declared for top-level volumes: block. The practical difference na ownership: use bind mounts for config wey you maintain, and named volumes for data wey application maintain.

Why I dey get "permission denied" with bind mount but no be named volume?

Empty named volume dey get seeded from image, so e usually inherit the ownership wey image set and container user fit write to am. E still dey enforce permissions, so e fit fail the same way if image no get anything for that path. Bind mount dey show host directory exactly as e be, and if Docker need create that directory, e create am owned by root. Run docker compose exec <service> id to see the numeric id wey container dey use, then sudo chown -R <uid>:<gid> the host directory, or, when daemon no dey remap user namespaces, set user: "1000:1000" for the service. If daemon dey use user-namespace remapping, chown to mapped host id instead, because container ids dey shift for host and user: no dey change that.

Where Docker dey store named volumes for disk?

With default local driver dem dey live under /var/lib/docker/volumes/<volume>/_data, and docker volume inspect <volume> dey print the exact Mountpoint. Read am if you need check something, but write to am only through container, because editing as root for host fit change ownership for ways wey container no go expect.

How I fit back up named volume?

Run short-lived container wey get both the volume and host directory mounted, then archive from one to the other with docker run --rm -v myvol:/data:ro -v "$PWD":/backup alpine tar czf /backup/myvol.tar.gz -C /data .. For database, dump with the database own tool instead of copying live files, because file copy wey happen while writes dey ongoing fit restore into corrupt state.

docker compose down dey delete my volumes?

docker compose down dey remove containers and networks and leave named volumes for place. docker compose down -v dey also delete every named volume wey project declare, permanently. Bind mounts no dey ever get removed by either command, because that directory belong to host, no be Docker.

Dis article help you?