Ch. 13 · Docker

Docker Bind Mounts and Host Coupling

Bind mounts map a host path into a container: great for live code, risky for ownership, portability and security. See where each one bites.

~6 min readintermediateupdated Oct 7, 2026

A bind mount takes a path on the host and makes it appear at a path inside the container. Both sides see the same files, live, in both directions. That’s exactly what you want when editing source code on your laptop and having a dev server inside a container reload it. It’s also why bind mounts tie a container to one particular machine’s directory layout, user IDs and filesystem, and why interviewers ask about them in the same breath as volumes.

The examples below were run on Docker Desktop for macOS with alpine:3.20. Where Linux behaves differently, and it does for ownership, that’s called out.

The mount hides what the image had

Alpine ships several files in /etc/apk. Bind-mount a host directory with one file over it:

mkdir -p src && echo "host file" > src/hello.txt
docker run --rm -v "$PWD/src":/etc/apk alpine:3.20 ls /etc/apk
Terminal
hello.txt
Text

The image’s files aren’t deleted, they’re just underneath the mount, invisible to the container. Volumes behave differently: an empty named volume gets seeded with the image’s content on first use. Bind mounts never do that.

This is the source of the most common Node.js-in-Docker bug. The Dockerfile runs npm ci into /app/node_modules, then Compose bind-mounts the project directory to /app for live reload. The host directory has no node_modules (or one built for macOS), so the container’s carefully installed Linux dependencies vanish and the app fails with Cannot find module 'express' or a native module error. The usual fix is to mount an anonymous or named volume at /app/node_modules as well, so that path is served from the volume instead of the host:

services:
  web:
    build: .
    volumes:
      - ./:/app
      - /app/node_modules
yaml

The more specific mount wins for its subtree. Compose’s develop.watch (sync files into the container rather than mounting) avoids the problem altogether and is worth mentioning as the newer approach.

-v versus --mount when the path is wrong

The two syntaxes do the same job but disagree about missing paths. --mount refuses:

$ docker run --rm --mount type=bind,src="$PWD/nope",dst=/x alpine:3.20 true
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /host_mnt/.../nope
Text

-v creates the directory for you and carries on:

$ docker run --rm -v "$PWD/nope2":/x alpine:3.20 true
$ ls -ld nope2
drwxr-xr-x  2 aditya  wheel  64 Oct  7 22:52 nope2
Text

That’s convenient right up until you mistype a path to a config file. -v ./nginx.conf:/etc/nginx/nginx.conf with a typo in the filename creates an empty directory called nginx.conf on the host and mounts it, and nginx then fails with an error about nginx.conf being a directory. In scripts, prefer --mount; the extra verbosity buys you a loud failure. Notice the /host_mnt prefix in the error too: on Docker Desktop the daemon runs in a VM, and your Mac’s paths are shared into it, which is also why bind-mount performance on macOS is slower than on Linux for large trees.

Ownership travels by number

Linux file permissions are stored as numeric user and group IDs, not names. A bind mount passes them through unchanged. So:

  • A container running as root (UID 0) that writes into a bind mount creates root-owned files on the host. Your normal user then can’t delete the build output without sudo.
  • A container running as UID 1000 can’t write to a host directory owned by UID 1001 with mode 755, and you get Permission denied even though “the user exists” in both places. Names are irrelevant, only the numbers matter.

Docker Desktop hides this. Running as UID 1234 on macOS:

$ docker run --rm -u 1234:1234 -v "$PWD/src":/src alpine:3.20 sh -c 'touch /src/by-1234; ls -ln /src'
-rw-r--r--    1 1234     1234             0 Oct  7 17:22 by-1234
-rw-r--r--    1 1234     1234            10 Oct  7 17:22 hello.txt
$ ls -ln src
-rw-r--r--  1 501  0   0 Oct  7 22:52 by-1234
-rw-r--r--@ 1 501  0  10 Oct  7 22:52 hello.txt
Text

Inside, every file appears owned by the container’s user. On the Mac, the file belongs to the Mac user (UID 501). The file-sharing layer translates ownership, so permission problems that a Linux colleague or the CI runner hits may simply not reproduce on a Mac. On a Linux host, the same touch creates a file owned by UID 1234, and ls -l on the host shows a bare number because no local user has that ID.

The practical fixes on Linux are to run the container as your own IDs (-u "$(id -u):$(id -g)", common for build tools), to build the image with a user whose UID matches the host’s, or to use a named volume instead and let the image own its data directory. The nonroot runtime note goes into the image side of this.

Read-only, and the security edge

Appending :ro (or readonly in --mount) makes the mount read-only for the container:

$ docker run --rm -v "$PWD/src":/src:ro alpine:3.20 sh -c 'echo x > /src/new.txt'
sh: can't create /src/new.txt: Read-only file system
Text

Use it for anything the container should consume but not change: config files, TLS certificates, source code for a linter. It’s a cheap guard against a bug, or an attacker, rewriting host files.

Without it, a bind mount is a hole straight through the container boundary. Mounting / or /etc from the host into a container that runs as root gives that container root-equivalent control of the host. Mounting /var/run/docker.sock is the same thing by another route, since whoever can talk to the daemon can start a privileged container; the docker.sock permission note explains why that’s not a permission to hand out casually. On SELinux hosts (Fedora, RHEL) you’ll also need the :z or :Z suffix so the container is allowed to read the files at all, and you should never use :Z on a system directory, because it relabels the host path.

When to reach for a bind mount

Bind mounts are the right tool when the host and container genuinely need to share a path: source code in development, a config file you edit on the host, a socket, build output you want on the host afterwards. They’re a weak choice for application data in production, because they depend on a directory existing with the right ownership on every machine the container might run on, and they don’t get the volume conveniences (named lifecycle, drivers, first-mount seeding). In an interview, the line “volumes are managed by Docker, bind mounts are managed by you” summarises it well, as long as you can explain what “managed by you” costs.

Interview practice

A CI job runs docker run --rm -v "$PWD":/work -w /work node:22 npm run build. The build succeeds, but the next CI step (running as the runner user, UID 1001) fails with EACCES: permission denied, unlink 'dist/main.js' when it tries to clean the output directory. It works fine on developers’ Macs. Explain and fix it.

Answer

The node:22 image runs as root by default, so the files it created in dist/ through the bind mount are owned by UID 0 on the Linux CI host. The runner user can’t remove files in a root-owned directory. Macs don’t show the problem because Docker Desktop’s file sharing maps ownership to the Mac user.

The simplest fix is to run the build as the CI user: docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/work -w /work node:22 npm run build. If npm needs a writable home directory for its cache, set -e HOME=/tmp or npm_config_cache=/tmp/.npm. Alternatives are to copy the output out with docker cp instead of writing through a mount, or to build in a multi-stage Dockerfile and extract the artifact. Fixing it with chmod -R 777 or a sudo rm step treats the symptom and leaves root-owned files on a shared runner.

Keep going

More in Docker

read ✓Docker · mid

Docker Nonroot Users and File Permissions

Switching a container to a nonroot USER breaks writes until ownership is right. Fix EACCES with chown, numeric UIDs and pre-created volume paths.

~5 min readread →
esc