Dash0 Raises $110M Series B at $1B Valuation

  • 14 min read

How to Manage Permissions for Docker Shared Volumes

Mount a directory into a container, write a file from inside, and watch the host complain about permission denied when you try to edit it. Or run a container as a non-root user, mount your project directory, and watch the container fail because it can't write to anything. It's the same problem either way: the container and the host share a kernel, which means they share the same numeric User ID (UID) and Group ID (GID) space, and nothing reconciles those numbers between them by default. The container thinks it's root, and the host sees a UID 0 process scribbling on your home directory.

Below are the patterns I keep reaching for to fix this on Linux, and the Docker Desktop quirks that quietly let it work on a Mac and then break in CI.

The model: UIDs and GIDs are numbers, names are labels

The Linux kernel only cares about numeric UIDs and GIDs. Usernames are convenience labels from /etc/passwd, and that file is different inside the container than on the host. A user called appuser with UID 1000 inside the container is, as far as the host kernel is concerned, the same identity as your local user with UID 1000. Same number, same permissions, regardless of what either side calls it.

Bind mounts are where this bites hardest. A bind mount isn't a separate volume managed by Docker; it's the host's filesystem exposed inside the container at a different path. The files keep their host UID and GID, and the container's process is checked against those numbers using the host's permission bits.

When something breaks, two commands tell you everything you need to know:

bash
12345
# Inside the container: who is the process running as?
docker exec <container> id
# On the host: who owns the files on the bind mount?
ls -ln /path/to/mounted/dir

Use -n (not -l alone) so ls shows numeric UIDs and GIDs. Names get resolved from the local /etc/passwd, which lies to you when the file owner is a user that exists in the container but not on the host.

Typical output for a broken setup:

text
12
uid=0(root) gid=0(root) groups=0(root)
-rw-r--r-- 1 1000 1000 240 Jun 2 14:22 main.py

Container running as UID 0, files owned by UID 1000. Anything the container writes will land on the host owned by root, and your IDE user can't touch it without sudo.

Fix 1: Run the container as your host user

For local development with bind mounts, the simplest fix is to tell the container to run as the same UID and GID as whoever owns the directory on the host:

bash
12345
docker run --rm -it \
-u "$(id -u):$(id -g)" \
-v "$PWD":/app \
-w /app \
node:22 npm install

The --user flag overrides any USER directive baked into the image. The process now runs as your UID, files it creates are owned by your UID on the host, and your editor can save without sudo.

The same pattern in compose.yml:

yaml
1234567
services:
app:
image: node:22
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
working_dir: /app

One bash gotcha: UID and GID aren't exported environment variables by default (zsh exports them; bash doesn't), so plain docker compose up will silently use the 1000 fallbacks regardless of who you actually are. Export them inline (UID=$(id -u) GID=$(id -g) docker compose up) or put the values in a .env file next to the compose file.

The catch with this whole approach: the UID you pass in might not exist as a user inside the container. Most images tolerate this (the process runs but whoami errors out), but some entrypoints fail when they can't resolve the UID to a name, and a few applications refuse to start as a UID that isn't in /etc/passwd. Postgres's initdb is the classic example: it doesn't care what UID Postgres itself runs as, but on first initialization of an empty data directory it tries to look up the effective user and fails if the UID isn't a known account. When you hit one of those, bake the user into the image instead.

Fix 2: Bake a non-root user into the image

If the image is going to other environments, create the user at build time:

dockerfile
1234567891011121314
FROM python:3.13-slim
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd --gid $USER_GID app && \
useradd --uid $USER_UID --gid $USER_GID --create-home app && \
mkdir -p /app && chown app:app /app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["python", "main.py"]

COPY --chown=app:app is the part people forget. Without it, files copied into the image are owned by root, and when the USER directive kicks in at runtime the application can't modify its own working directory. The image builds fine, the container starts fine, and then your app crashes the first time it tries to write a cache file.

The other easy miss is the chown on /app itself in the RUN step. --chown on COPY only sets ownership of the files being copied, not of the destination directory. If WORKDIR /app is the first thing that creates /app, the directory is owned by root with mode 755, and the app user can read files inside it but can't create new ones at its root — log files, cache files, anything written next to the source. Chowning the directory once during user creation avoids that, and it's cheap.

For local development where every developer has a different host UID, parameterize the build:

bash
1234
docker build \
--build-arg USER_UID=$(id -u) \
--build-arg USER_GID=$(id -g) \
-t myapp .

This rebuilds per-developer, which is fine for dev images and bad for shared registry images. For images you actually ship, pick a stable UID (something high like 10001 is conventional for non-system users) and document it somewhere future-you will find it.

Fix 3: chown in the Dockerfile for named volume mount points

Named volumes behave differently from bind mounts in one important way. When a named volume is mounted onto a path that already exists in the image, and the volume is empty (first run only), Docker copies the contents of that path into the volume, ownership and permissions included.

So a chown in the Dockerfile gets baked into the volume on first use:

dockerfile
12345678
FROM debian:bookworm-slim
RUN groupadd --gid 999 app && \
useradd --uid 999 --gid 999 --no-create-home app && \
mkdir -p /var/lib/myapp/data && \
chown -R 999:999 /var/lib/myapp/data
USER 999

Build the image, then mount a named volume at that path when you run it:

bash
1
docker run -v myapp_data:/var/lib/myapp/data myimage

On the first mount of the empty myapp_data volume, Docker copies the directory's contents — and the 999:999 ownership — into the volume. Subsequent runs reuse the volume as-is. Docker does not re-apply the image's ownership on later mounts.

Here's the trap: if you change the UID in the Dockerfile after the volume already exists, the volume keeps its old ownership and the container fails to write to it. You can either chown the volume manually with a one-off container, or recreate it:

bash
123456
# Option A: chown the existing volume
docker run --rm -v myapp_data:/data alpine \
chown -R 999:999 /data
# Option B: recreate the volume (only if the data is disposable)
docker volume rm myapp_data

If your data isn't disposable, option A. Always. (Removing containers cleanly is its own minefield: docker rm leaves anonymous volumes behind by default.)

Named volumes vs bind mounts: the real difference

Bind mounts are a window onto the host filesystem. Host permissions apply directly, and Docker adds nothing on top. If your host user is UID 1000 and the container runs as UID 0, writes from the container land on the host as root-owned files. On Linux there's no translation layer doing helpful background magic. What ls -ln shows is what you actually have.

Named volumes are managed by Docker under /var/lib/docker/volumes/. On first mount of an empty volume, the contents of the container directory get copied in, ownership and permissions included. After that, the volume is a normal directory on the host that follows the container's effective UID and GID for any new files written through it.

Bind mounts make sense when you need to read and edit the same files from both sides: source code during development, config files you template on the host, log directories you want to inspect with grep instead of going through docker exec. Named volumes make sense for state that only the container looks at: databases, caches, persistent app data the host shouldn't touch directly. The permission story is simpler with named volumes because Docker controls the volume's identity instead of inheriting whatever happened to be on the host.

A common mistake is assuming a named volume will reset its ownership when you change the container user. It won't. The volume keeps whatever ownership existed at first mount, forever, until you change it explicitly.

Docker Desktop on macOS and Windows: different rules

Docker Desktop doesn't run containers natively. It spins up a Linux VM and runs Docker inside it. Bind mounts from your Mac or Windows host go through a filesystem translation layer: VirtioFS in recent Docker Desktop versions, with gRPC FUSE as the older alternative still selectable in Settings → General.

That layer is convenient. It's also why a lot of permission bugs hide in plain sight on a developer's laptop.

On macOS, bind-mounted files appear inside the container with whatever UID the container process happens to be running as. Docker Desktop fakes the ownership so reads and writes succeed regardless of what UID the host file actually has. chown from inside the container against a bind mount is effectively a no-op on the host filesystem. One smaller trap: the default macOS user has UID 501, not 1000, which catches people moving Dockerfiles from Linux dev environments where they hardcoded 1000.

On Windows with the Windows Subsystem for Linux 2 (WSL2) backend, the rules split based on where the files live. Files stored under /home/<user>/ (the Linux filesystem inside WSL) follow normal Linux UID and GID rules. Files under /mnt/c/ (the Windows filesystem accessed via the 9P protocol) follow Windows-style permissions translated by the WSL kernel, usually owned by your WSL user regardless of what the container writes.

The result is a Dockerfile that works on a developer's Mac and breaks in CI on Linux. The container ran as root the whole time. On macOS the file sharing layer translated everything and nobody noticed. On Linux the root-owned files in the bind mount break the next pipeline step that expects to read them as a non-root CI user.

My rule is to always run with --user even when developing on Docker Desktop and the laptop wouldn't strictly require it. The local environment then behaves like Linux production and CI, and permission issues surface where they're cheap to fix rather than in a deployment that worked yesterday.

Final thoughts

Volume permission errors look trivial until they're the thing blocking a deployment late on a Friday. A container that crashes on startup because it can't write to its data volume isn't something you want to learn about from a customer reporting that uploads stopped working. Tracking container resource usage, restart counts, and filesystem error logs catches this class of problem before it turns into an incident.

Dash0's infrastructure monitoring captures container runtime state, real-time logs, and distributed traces using OpenTelemetry, so permission failures and the cascading errors they cause land in one place alongside the rest of your infrastructure signals. If centralized Docker log collection is the next thing on your list, Mastering Docker Logs walks through the OpenTelemetry Collector setup end to end. Start a free trial to see your containers, volumes, and logs in a single view. No credit card required.