You own the container developer experience for a team split across macOS with Docker Desktop, native Linux workstations, and Linux CI runners, and bind-mount file ownership behaves differently on each. How would you design a strategy that holds up across all three?
answer
- classify mounts: human-edited vs container-written
- named volumes for node_modules / target / db data
- one mechanism team-wide: --user ${UID}:${GID} + HOME set
- Docker Desktop fabricates ownership → validate on Linux CI
- rootless Docker: container root maps to my UID
basics
~10 sDecide first which paths humans must edit; everything else moves to named volumes. For the remaining bind mounts, standardise one identity mechanism, and treat CI as the reference environment because Docker Desktop hides mismatches.
solid answer
~50 sStart by classifying paths. **Source code** must be a bind mount because humans edit it. **Build output, dependency trees, caches, and databases** need not be — move them to named volumes and the whole mismatch disappears for the majority of writes, with a Docker Desktop I/O win as a bonus. For what remains, pick **one** identity mechanism and apply it everywhere: I default to runtime `--user "${UID}:${GID}"` driven from a Compose variable, with `HOME` set explicitly and writable paths group-writable, because it keeps a single published image for all hosts. Make **Linux the reference environment**. Docker Desktop fabricates ownership through its VM share, so a macOS-only team ships broken Compose files that fail the first time a Linux developer or the CI runner touches them. Gate the setup in CI on a real Linux runner. Longer term, evaluate **rootless Docker**: container root maps to the invoking user's UID, which removes the class of bug rather than patching each image.
code
yaml · 14 linesservices:
app:
image: registry.example.com/app:dev
user: "${UID:-1000}:${GID:-1000}"
environment:
HOME: /tmp
NPM_CONFIG_CACHE: /cache/npm
volumes:
- .:/src
- node_modules:/src/node_modules
- npm_cache:/cache/npm
volumes:
node_modules:
npm_cache:go deeper
Not expected to design this. Know that behaviour differs by platform and that the team convention exists for a reason.
Be able to describe the three environments' differences and implement the chosen convention correctly, including moving dependency directories to named volumes.
Propose and justify one mechanism, and set up the Linux CI check that stops macOS-only validation from hiding regressions.
Own the decision and its rationale in writing: what belongs on a bind mount at all, one enforced mechanism, Linux as the reference environment, and an explicit evaluation of rootless Docker with its networking and capability costs.
## Why the three environments disagree **Native Linux.** Bind mounts are raw passthrough. The container process's numeric UID is written into host inodes, and any mismatch with the developer's UID produces either denied writes or root-owned artifacts. Developer UIDs are usually 1000 but not reliably so. **Docker Desktop (macOS/Windows).** The host path crosses a VM boundary via virtiofs or a FUSE-style share, and that layer *fabricates* ownership: files are presented as owned by whoever is looking. Permission mismatches simply do not surface. This is convenient and dangerous — it means the majority of a team can develop for months on a configuration that is broken on Linux. **Linux CI runners.** Typically a fixed unprivileged UID (GitHub's hosted runner uses 1001; self-hosted runners vary), often different from every developer's. Containers writing as root leave artifacts the runner user cannot clean, so the workspace-cleanup step of the *next* job fails — a failure that appears unrelated to the change that caused it. Some runners also execute the job itself inside a container, adding a second layer of identity. Additional variants worth knowing about, because someone on the team will be running one: **rootless Docker**, where `/etc/subuid` maps container UID 0 to the invoking user's host UID (so container root produces developer-owned files, and *non-zero* container UIDs map to large host UIDs nobody owns); **`userns-remap`** on a rootful daemon, which does the same for a dedicated remap user; and **Podman**, which is rootless by default and behaves like the rootless case. ## Design principle 1 — shrink the surface Most of the pain is self-inflicted: container-owned state written onto human-owned filesystems. Classify every mount: - *Humans edit it, containers read it* — source code, local config. Must be a bind mount. Read-mostly, so mismatches are mild and often solvable with mode bits alone. - *Containers write it, humans occasionally inspect it* — `node_modules`, `target/`, `.gradle`, `.venv`, database data directories, caches. Put these in **named volumes**. Ownership stays inside Docker, no host user is confused, and on Docker Desktop the I/O is dramatically faster because it never crosses the VM share. - *Containers write it, humans need the artifact* — build output, coverage reports, generated code. Keep it in a volume during the build and extract deliberately with `docker cp` or a final `--output` stage, so exactly one well-defined step decides ownership. This alone removes most of the problem. ## Design principle 2 — one identity mechanism, uniformly applied Pick one and forbid the others in review, because a codebase where some services chown in an entrypoint, some bake `ARG UID`, and some run as root is unmaintainable. My default: runtime `--user "${UID:-1000}:${GID:-1000}"` in Compose, with `UID`/`GID` exported by a small bootstrap script or direnv. One image serves everyone; nothing per-developer is baked in. The known costs must be paid up front in the image: set `HOME` to a writable path, set tool cache directories explicitly (`NPM_CONFIG_CACHE`, `GRADLE_USER_HOME`, `PIP_CACHE_DIR`), and make writable paths group-0 writable so an arbitrary UID still works. The alternative default — a root entrypoint that chowns then `exec gosu` — is more forgiving and is what most official images do, but it conflicts with policies requiring non-root startup, and recursive chowns of large dependency trees cost real seconds on every start. ## Design principle 3 — make Linux authoritative Docker Desktop's fabricated ownership means macOS cannot validate the design. Run the developer-environment smoke test (build, run, write to each mount, then have the *host* user delete what was written) on a real Linux runner in CI, on every change to the Dockerfiles or Compose files. That converts a class of "works on my machine" report into a failed check on the PR that introduced it. Give CI its own explicit answer rather than hoping the developer setup transfers: either pass the runner's UID the same way, or accept root-owned output and add an explicit ownership-fixing step before cleanup. Choosing consciously is the point. ## Design principle 4 — consider removing the class of bug Rootless Docker (or Podman) makes container root map to the developer's own UID. For the overwhelmingly common "container runs as root and writes my source tree" case, files come out correctly owned with no flags at all. The tradeoffs are real — different networking (slirp/pasta performance), some storage-driver constraints, non-zero container UIDs appearing as unowned high numbers on the host, and images that assume they can bind low ports or load kernel modules failing. Evaluate it as a platform decision with a migration plan, not as a per-team workaround. ## How to decide Weigh: how many images do you publish versus build locally (favours runtime `--user`); how heterogeneous are host UIDs (favours runtime over build-time); is non-root startup mandated by policy (rules out the chown entrypoint); how large are the trees a chown would traverse; and how much appetite exists for a daemon-level change. Write the decision down with its rationale, because the next person will otherwise re-derive a different answer for one service and reintroduce the drift.
- What is the strongest argument against standardising on a build-time ARG UID?It makes the image per-developer, so the artifact CI builds and tests is not the artifact anyone runs, and any host whose UID differs silently reverts to the original bug. It also multiplies the local image matrix and defeats layer-cache sharing across the team. Build-time UID is defensible only in a homogeneous, fully controlled environment.
- Where does rootless Docker stop helping?It fixes the common case where the container runs as root, because container UID 0 maps to your host UID. But an image with a non-zero USER maps into your subordinate UID range and shows up on the host as a large unowned number, so hardened non-root images still need alignment. Rootless also changes networking and blocks capabilities some images expect, so it is a platform migration rather than a drop-in flag.
- Why is CI often where this surfaces first even though nothing in CI is unusual?CI runs on native Linux with a fixed runner UID that matches nobody's laptop, and it runs cleanup steps as that user. A macOS-developed setup carries no evidence of mismatch because Docker Desktop fabricates ownership, so the first honest test of the design is the pipeline — usually as a confusing failure in the *next* job's workspace cleanup.
saying these in an interview costs you the question
- Treating Docker Desktop behaviour as proof the configuration is correct
- Letting each service pick its own mechanism, so the repo mixes ARG UID, entrypoint chowns, and plain root
- Bind-mounting dependency and cache directories that no human ever edits
- Adding chmod 777 to CI as the cleanup fix
- Proposing rootless Docker as a trivial flag flip with no networking or capability tradeoffs