Skip to content
Blog

Bind Mounts: Wiring Host Directories into Containers

-v vs --mount, live code reload, read-only mounts, UID mismatches, SELinux labels and VirtioFS performance — plus how to debug 'mount denied'.

Published on • October 31, 2026

AI Assistant

A bind mount punches a hole between the host filesystem and the container: the same bytes, visible from both sides, updated in real time. It’s how live-reload development works, how configs get into containers, and how output lands on the host — and it’s where Docker’s portability story breaks, because host paths, UIDs, SELinux labels and OS-specific file sharing all leak into your setup. Here’s how to use them deliberately.

-v vs --mount: same mount, different syntax

# short form
$ docker run -d --name dev -v "$(pwd)/src:/app/src" my-app:dev

# long form
$ docker run -d --name dev \
    --mount type=bind,source="$(pwd)/src",target=/app/src \
    my-app:dev

Docker recommends --mount: it’s explicit (type=bind leaves nothing to inference), key-value based, and supports options the short form can’t express (like bind-recursive and bind-create-src). The differences that actually bite:

-v / --volume--mount
Syntaxhost:container[:opts] colon-separatedkey=value comma-separated
Missing host pathCreates the directory (always as a directory, even if you meant a file)Errors: bind source path does not exist (or pass bind-create-src)
Relative host pathsSupported since Engine 23 (-v ./content:/content)Supported
SELinux z/ZYesNo — SELinux relabeling can only be set with -v
TyposSilently create empty dirs or mount the wrong thingFail loudly

That first row is a classic footgun: -v /home/me/config:/etc/app when the path is slightly wrong gives you an empty directory mounted over your app’s config, and the container starts with defaults and no error. --mount refuses to start instead.

The destination path inside the container must be absolute. The host path should be absolute too — $(pwd) is the portable way to write it, since a relative path resolves against the daemon’s working directory, not yours, on remote daemons.

The live-reload use case

This is where bind mounts earn their keep:

$ docker run -d --name dev \
    --mount type=bind,src="$(pwd)",dst=/app \
    -w /app node:22 \
    npm run dev

Edit a file on the host and the watcher inside the container sees it immediately — no rebuild, no docker cp, no committing layers. The same pattern serves build artifacts (./target:/app for a Java service), config files (-v ./nginx.conf:/etc/nginx/nginx.conf:ro), and output directories (-v "$(pwd)/out:/out" for a render job).

Mounting over a non-empty directory in the container obscures its previous contents — like plugging a USB drive over /mnt. That’s useful (test new dist/ output against the image’s runtime) and dangerous (-v /tmp:/usr breaks the image, and there’s no unmount-and-reveal: recreate the container without the mount).

Read-only mounts: :ro

Anything the container shouldn’t mutate gets ro:

$ docker run --mount type=bind,src="$(pwd)/config",dst=/etc/app,readonly ...
$ docker run -v "$(pwd)/config:/etc/app:ro" ...

Combine with --read-only to make the entire root filesystem immutable, granting write access only where you mount volumes or tmpfs:

$ docker run --read-only -v /icanwrite busybox touch /icanwrite/here

A read-only config mount turns “the container edited its own config and now won’t start next time” into an impossible failure — cheap insurance, and the failure mode shifts to an obvious read-only-FS error you can see in the logs.

The permission problem: UIDs, GIDs, and who owns what

Bind mounts carry host ownership into the container, and the two sides frequently disagree.

The basic case (rootful Linux Docker). The container runs as root (UID 0) or as whatever USER the image sets. Files the container creates on the mount are owned by that UID on the host. A container running as UID 10001 writing to a directory owned by your host user (UID 1000) gets Permission denied — and vice versa. Fix it by aligning the two:

# what the host sees
$ ls -ln ./data          # 1000  1000  data

# run the container as the same user
$ docker run -u "$(id -u):$(id -g)" -v "$(pwd)/data:/data" my-app

or chown the host directory to the UID your image’s USER expects. On an existing mount, docker exec -u root plus chmod/chown works but resets on host-side edits — aligning at creation is cleaner.

Rootless Docker changes the arithmetic. In rootless mode the daemon and containers run inside a user namespace: container UID 0 maps to your host UID, and container UID n ≥ 1 maps into the subordinate range from /etc/subuid. The practical consequence, straight from Docker’s UID/GID mapping docs: files owned by your host user appear as owned by root inside the container, while files created by a non-root user inside land at host UIDs like 101000. If a tool inside the container needs to write to the mount and isn’t running as root, you’re doing arithmetic against /etc/subuid. Many people decide this is the point to switch that workload to a named volume.

Docker Desktop (macOS/Windows). The daemon runs inside a Linux VM and maps host paths into it — but only paths you’ve shared. On macOS, /Users, /Volumes, /private, /tmp and /var/folders are shared by default; a project elsewhere fails until you add it under Settings → Resources → File sharing (Windows: drive-letter shares like C:\projects). Two more gotchas: Linux containers are case-sensitive while macOS and Windows are only case-preserving, so Test vs test collides with File exists; and file ownership inside the VM rarely matches your host user, so docker run -u $(id -u) behaves differently on Desktop than on a Linux server.

SELinux: z and Z

On SELinux-enforcing hosts (RHEL, Fedora, CentOS), the host labels bind-mounted content, and container processes are only allowed to touch files labeled for containers. Symptom: Permission denied on a directory that’s chmod 777. Fix with the -v suffixes:

$ docker run -v "$(pwd)/data:/data:z" ...    # shared: other containers may use it too
$ docker run -v "$(pwd)/data:/data:Z" ...    # private to this container

z relabels the content with a shared label; Z makes it exclusive. Two warnings: these options modify the host’s labels — never :Z a system directory like /home or /usr (you can make the machine unbootable until you relabel) — and they’re ignored for Docker services. --mount can’t set them at all; use -v on SELinux hosts.

Performance: macOS and Windows realities

On Linux with the daemon on the same machine, bind mounts are plain kernel mounts — near-native speed. On Docker Desktop every read and write crosses a VM boundary through a file-sharing layer:

  • VirtioFS (default on macOS) replaced gRPC-FUSE and is dramatically faster — Docker’s testing showed filesystem operations improving by up to 98% — but it’s still a network-filesystem-like protocol, not ext4; gRPC-FUSE remains a selectable fallback.
  • Synchronized file shares (Pro/Team/Business) cache host files on ext4 inside the VM — aimed at monorepos with 100,000+ files where virtual filesystems stop scaling.

Either way: share only the directories you need (the docs warn that sharing too many files can cause high CPU load), keep node_modules and build caches inside the container rather than on the mount, and prefer a Linux host for I/O-heavy bind-mounted workloads.

When to prefer a named volume

Bind mounts couple the container to one machine’s layout. Reach for a named volume when:

  • The data is application state (databases, uploads) rather than development material — volumes survive image updates, are portable across OSes, and don’t depend on host paths.
  • You need backup/migration workflows (docker run --volumes-from, volume drivers).
  • You’re on rootless Docker and fighting UID mapping on every write.
  • The container must be relocatable — the same Compose file on a teammate’s machine, CI, and production. A bind mount path that exists on your laptop may not exist anywhere else; a named volume never asks.

Rule of thumb: dev environment material (source, configs, output) → bind mount; data → named volume. The full trade-off table is in Docker Volumes vs Bind Mounts vs tmpfs.

Debugging “mount denied”

$ docker run -v /doesnt/exist:/app alpine
docker: Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /doesnt/exist.
  • bind source path does not exist — with --mount, the source must exist (or add ,bind-create-src); with -v, a missing source is silently created as a directory.
  • permission denied creating the source — the daemon needs permission, not you; pre-create it with the right ownership.
  • mount destination ... must be an absolute path — your container path lost its leading /, or a variable expanded to nothing.
  • Works locally, fails on the server — bind mounts resolve on the daemon host, not the CLI machine: a remote daemon can’t see your laptop’s files, and on Docker Desktop the path must be inside a shared directory.
  • SELinux: permission denied — add :z/:Z with -v (see above).
  • Mount “works” but the app sees no config — you mounted over a non-empty directory and obscured its contents; verify with docker inspect <name> → Mounts:
"Mounts": [
  { "Type": "bind", "Source": "/home/me/app/src",
    "Destination": "/app/src", "Mode": "", "RW": true,
    "Propagation": "rprivate" }
]

Check Source (is it the path you meant?) and RW (did your :ro land?). If both are right and the app still can’t read, it’s a UID or SELinux problem — docker exec <name> ls -ln /app/src tells you which UID owns the files as the container sees them.

Further reading