Repository navigation
chore(dev): dev container uid cannot match host, breaking Air hot reload on Linux bind mounts #158
Description
Surfaced by CodeRabbit on PR LeanerCloud/cloud-commitments-cli#1700. The finding is real, but it predates that PR, so it is filed separately rather than folded in.
What
docker-compose.yml bind-mounts the checkout at .:/app for the app (dev) service, which hides the image-layer ownership Dockerfile.dev sets with chown. .air.toml has root = ".", tmp_dir = "tmp", bin = "./tmp/main", so Air must write /app/tmp/main inside that bind mount on every reload.
On Linux, the mounted files carry the host user's uid/gid. The container user's uid is fixed at image build time and has no relationship to whoever runs docker compose up. When they do not match, /app is not writable, Air cannot build, and the hot-reload loop stops silently.
Why this is pre-existing, not new
It is tempting to attribute this to PR LeanerCloud/cloud-commitments-cli#1700 pinning the dev user to 1001:1001. It is not:
- Before chore(docker): adopt hadolint 2.15.1 and fix the four findings it reports cloud-commitments-cli#1700, the user was created with
addgroup -S devuser && adduser -S devuser -G devuser. Verified against the pinned base image, that yields uid=100, gid=101. - uid 100 is a system id that will essentially never match a host developer account (typically 1000 or 1001).
- So the mismatch existed already. chore(docker): adopt hadolint 2.15.1 and fix the four findings it reports cloud-commitments-cli#1700 changes the fixed id from 100 to 1001, which is if anything more likely to match a Linux host, and does not change the shape of the problem.
Name-form vs numeric-form USER is irrelevant here: USER devuser resolves to the same fixed uid baked at build time. Neither form adapts to the host.
Not reproducible on macOS
On Docker Desktop for macOS the bind mount is ownership-remapped, so the container sees /app owned by whatever uid it runs as and the write always succeeds. Confirmed both ways:
original image (uid 100): ls -ldn /app -> 100 101 ... /app WRITABLE, tmp write OK
LeanerCloud/cloud-commitments-cli#1700 image (uid 1001): ls -ldn /app -> 1001 1001 ... /app WRITABLE, tmp write OK
So a macOS developer cannot observe this, and a macOS test cannot be used as evidence either way. It needs a Linux host (or a Linux CI runner) to reproduce.
Fix direction
CodeRabbit's suggestion is the standard one and is sound:
- Accept
DEV_UID/DEV_GIDbuild args inDockerfile.dev, use them inaddgroup/adduser, and havedocker-compose.ymlpass the host's ids (user: "${DEV_UID:-1000}:${DEV_GID:-1000}"or viabuild.argsfed from an.env). - Alternatively, mount Air's output on a named volume so
/app/tmpis container-owned and never touches host ownership. This is the smaller change and avoids per-developer configuration, at the cost of the built binary not being visible on the host.
The named-volume option is probably the better default: it needs no .env and no per-machine setup, and nothing outside the container consumes tmp/main.
Acceptance
- On a Linux host with a checkout owned by a uid other than the container's,
docker compose up appreloads successfully on a source edit. - Whichever mechanism is chosen is documented next to it, so the next person changing
USERinDockerfile.devknows the bind mount is a constraint.
Activity