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:
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_GID build args in Dockerfile.dev, use them in addgroup/adduser, and have docker-compose.yml pass the host's ids (user: "${DEV_UID:-1000}:${DEV_GID:-1000}" or via build.args fed from an .env).
- Alternatively, mount Air's output on a named volume so
/app/tmp is 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 app reloads successfully on a source edit.
- Whichever mechanism is chosen is documented next to it, so the next person changing
USER in Dockerfile.dev knows the bind mount is a constraint.
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.ymlbind-mounts the checkout at.:/appfor theapp(dev) service, which hides the image-layer ownershipDockerfile.devsets withchown..air.tomlhasroot = ".",tmp_dir = "tmp",bin = "./tmp/main", so Air must write/app/tmp/maininside 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,/appis 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:addgroup -S devuser && adduser -S devuser -G devuser. Verified against the pinned base image, that yields uid=100, gid=101.Name-form vs numeric-form
USERis irrelevant here:USER devuserresolves 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
/appowned by whatever uid it runs as and the write always succeeds. Confirmed both ways: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:
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)./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
.envand no per-machine setup, and nothing outside the container consumestmp/main.Acceptance
docker compose up appreloads successfully on a source edit.USERinDockerfile.devknows the bind mount is a constraint.