skip to content

A container that runs as a non-root user gets "permission denied" writing to a freshly created Docker named volume mounted at /data, but the same image works fine when the volume is mounted over a path the image already contains. Explain the ownership rules Docker applies when a volume is first mounted, and how to fix it.

level: middleimportance: should knowfreq 40%

answer

  1. empty named volume seeded from image path, ownership preserved
  2. path missing in image → mount point created root:root 0755
  3. bind mount never seeds — host ownership always wins
  4. mkdir + chown in Dockerfile, or chown then gosu at entrypoint
  5. seeding is one-shot; stale volume beats new image defaults

basics

~20 s

On first use, Docker copies the image's content at that path into an empty named volume, ownership and modes included. If the path does not exist in the image, Docker creates it root-owned 0755, so a non-root process cannot write.

solid answer

~50 s

Docker seeds an **empty named volume** from the image: whatever exists at the mount path in the image layer is copied in, preserving UID, GID, and permission bits. So if the Dockerfile did `RUN mkdir -p /data && chown app:app /data`, the volume comes out owned by `app` and the non-root process can write. If the path does *not* exist in the image, the daemon simply creates the mount point, and it lands `root:root` mode 0755. A process running as `USER app` then gets `EACCES` on the first write — exactly the reported symptom. The seeding only happens once, and only for empty named volumes. It never happens for bind mounts, where the host directory's existing ownership always wins, and it does not re-run on later starts even if the image changes. Fixes, in order of preference: create and chown the directory in the Dockerfile; or chown it in an entrypoint before dropping privileges; or make it group-writable for GID 0.

code

dockerfile · 7 lines
dockerfile
FROM debian:bookworm-slim
RUN useradd -u 10001 -m app \
 && mkdir -p /data \
 && chown -R app:app /data
USER app
VOLUME /data
CMD ["/usr/local/bin/server"]

go deeper

for a junior

Know that an empty named volume is populated from the image path and that a missing path yields a root-owned mount point, so the Dockerfile should mkdir and chown it.

for a middle

Distinguish all three mount cases confidently, explain that ownership and modes are preserved during seeding, and give both the build-time and entrypoint fixes.

for a senior

Add the one-shot nature of seeding and its config-drift consequence, the GID-0 pattern for arbitrary UIDs, and how NFS/CIFS drivers override ownership entirely.

for a principal

Treat volume content as durable state with its own lifecycle and migration story, and set a house rule for who owns writable paths in images so every service does not solve it differently.

## Three different mount behaviours Docker's ownership behaviour at mount time depends on which kind of mount you asked for, and the difference surprises people because the CLI syntax is nearly identical. 1. **Bind mount** (`-v /host/path:/data`). The host directory is exposed as-is. Its existing owner and mode apply. Docker copies nothing and changes nothing. Whatever the image had at `/data` is hidden, not merged. 2. **Named volume, already populated** (`-v mydata:/data` where `mydata` has content). Mounted as-is; existing ownership inside the volume applies. No copy. 3. **Named volume, empty and new** (`-v mydata:/data` on first use). This is the special case: Docker copies the image's content at `/data` into the volume, **preserving ownership and permission bits**, then mounts it. This is how a database image can ship default config or an initialised directory and still let you keep the data outside the container. ## Why the reported failure happens Case 3 only helps if the path exists in the image. If the Dockerfile never created `/data`, there is nothing to copy; the daemon just materialises the mount point. Directories created that way are owned by `root:root` with mode 0755 — writable only by root. A container whose `USER` is `app` (say UID 10001) then fails on its first write with `EACCES`, while the very same image works when the volume targets a path the image *did* create with correct ownership. That asymmetry is exactly what the question describes. ## The fix, in the image Create the directory during build with the ownership the runtime user needs. The seeding copy then carries that ownership into the volume: ```dockerfile RUN useradd -u 10001 -m app \ && mkdir -p /data \ && chown -R app:app /data USER app VOLUME /data ``` Two details matter. First, an empty directory is still "content" for seeding purposes, so this works even though `/data` holds no files. Second, `COPY --chown=app:app` is the cheap way to set ownership on copied content without an extra `chown -R` layer that duplicates every file. The `VOLUME` instruction is documentation and a default; it does not change ownership rules, and it has the side effect of making later `RUN` steps that write to that path silently discard their changes, so declare it after everything is set up — or omit it and let the operator decide. ## The fix, at runtime If you cannot rebuild the image, start as root, chown the mount point, then drop privileges: ```sh #!/bin/sh chown app:app /data exec gosu app "$@" ``` This is what most official database images do internally. It is robust because it fixes whatever ownership the volume happens to have, at the cost of starting with enough privilege to chown. ## The fix, for arbitrary UIDs When the platform assigns an unpredictable UID at run time, you cannot chown to a known user. The portable pattern is to make the path group-writable for GID 0, because arbitrary-UID runtimes typically still assign the primary group 0: ```dockerfile RUN mkdir -p /data && chgrp -R 0 /data && chmod -R g+rwX /data ``` ## Seeding is one-shot, and that bites The copy happens only when the volume is empty. Once the volume has content, a new image version's updated defaults are *not* copied in — the stale volume content wins. Teams hit this when an image ships new config defaults and the change appears to have no effect; the resolution is to treat volume content as data that must be migrated, not as something the image can refresh. Likewise, changing the ownership in a new image version does not repair an existing volume; only an explicit chown does. Also note that seeding applies to Docker's local volume driver semantics. Plugin drivers and remote filesystems (NFS, CIFS, cloud file shares) may ignore or override ownership entirely — NFS with `root_squash` maps container root to `nobody`, and CIFS mounts usually pin ownership through mount options such as `uid=` and `gid=` rather than honouring on-disk metadata. ## Diagnosing ``` docker volume inspect mydata # find the Mountpoint sudo ls -ln <Mountpoint> # numeric owner of the volume root docker run --rm -v mydata:/x alpine ls -ln /x docker exec <container> id ``` Compare the numeric owner of the volume root against the container's numeric UID. If they differ and the mode grants nothing to "other", you have your answer without guessing.

  • You ship a new image version whose /data contains updated default config, but running containers still see the old files. Why?
    Volume seeding from the image happens only when the named volume is empty. Once it has content, Docker mounts it as-is and never re-copies, so the volume's old files shadow the image's new ones. Updated defaults must be handled as a data migration — an entrypoint that reconciles config, a versioned config path outside the volume, or an explicit operator step to recreate the volume.
  • Does a bind mount get the same ownership seeding?
    No. Bind mounts are never seeded and never merged: the host directory is exposed exactly as it is, and its existing owner and mode govern access. If the host path did not exist, Docker creates it root-owned, which is the bind-mount analogue of the same failure.

saying these in an interview costs you the question

  • Believing Docker chowns volumes to the container's USER automatically
  • Expecting bind mounts to be seeded from the image the way empty named volumes are
  • Assuming a new image version refreshes files already present in a volume
  • Using VOLUME early in a Dockerfile and then wondering why later RUN writes to that path vanish
  • Fixing it with chmod 777 inside the container instead of correcting ownership

context