You edited a config file on the host, reloaded your service, and nothing changed. The container is still serving the old content. You docker compose exec in, cat the file, and it's somehow different from what's on the host. No errors. Nothing in the logs. Just wrong. This post explains why, and how to fix it in one line.
The Problem
The Symptom
services:
app:
volumes:
- ./config.yaml:/etc/app/config.yaml:ro
You edit config.yaml on the host, run whatever reload command your app supports, and nothing changes. Inside the container the file still has the old content:
$ cat ./config.yaml | grep some_key
some_key: new_value
$ docker compose exec app cat /etc/app/config.yaml | grep some_key
some_key: old_value
Same path, same file, different content. No errors anywhere. Every reload succeeds. Nothing in the logs tells you why the container is stuck in the past.
The Cause: Inode Pinning
When Docker bind-mounts a single file, the kernel binds to that file's inode, not its path. The mount holds onto that specific inode for the life of the container.
Atomic file replacement — which is what git pull, vim, sed -i, cp, mv, and essentially every modern editor does — writes the new content to a temporary file and then rename(2)s it over the original. This is safer than in-place edits: there's no window where the file is half-written. But it produces a new inode. The directory entry now points to the new file; the old inode becomes orphaned, kept alive only because the container mount still references it.
Prove it yourself:
$ stat -c 'inode=%i' ./config.yaml
inode=12345
$ docker compose exec app stat -c 'inode=%i' /etc/app/config.yaml
inode=12345
# Edit the file (git pull, vim, whatever)
$ stat -c 'inode=%i' ./config.yaml
inode=67890 # new
$ docker compose exec app stat -c 'inode=%i' /etc/app/config.yaml
inode=12345 # still the old, orphaned inode
The host and container are now looking at two different files at the same path.
Why Reload and Restart Don't Help
In-container operations — app reload, kill -HUP, docker compose restart, docker compose exec ... reload — all run inside the container's existing mount namespace. They don't re-resolve the bind mount. The mount is still pinned to the old inode, so the running process can read that file a thousand times and never see the new content.
docker compose up -d --force-recreate <service> does fix it, because destroying and recreating the container produces a fresh mount that resolves to the current inode. But if you chose single-file bind mounts specifically to get zero-downtime reloads, force-recreate defeats the point.
The Solution
Mount the Parent Directory
services:
app:
volumes:
- ./config:/etc/app:ro
Directory bind mounts work differently. The kernel binds the directory; files inside are looked up by name on every access, not once at mount time. A new inode for config.yaml is resolved freshly every time the container reads it.
After the switch, git pull → app reload works. No force-recreate, no dropped connections.
Trade-offs:
- The whole directory is exposed to the container. Put the config in a dedicated subdirectory so siblings don't leak in.
- Read-only (
:ro) still works and is still recommended. - If the target path already has files you don't want shadowed, mount at a neutral path instead and point your app at it explicitly:
./config:/config:roplus whatever-config /config/file.yamlflag your app accepts.
Rarely-Worth-It Alternatives
In-place edits. sed -i on GNU coreutils can preserve inodes on some filesystems; vim with :set backupcopy=yes stops atomic-save behavior. Both are fragile — one collaborator using a different editor reintroduces the bug silently, and git pull always atomic-replaces regardless of settings.
Force-recreate every time. Simple, works, but you lose whatever benefit the bind mount was giving you over a baked-in config file.
Bake the config into the image. If it rarely changes, COPY it in the Dockerfile and rebuild on every change. Reloads become redeploys. Simpler than fighting Docker.
Parent-directory mounts are almost always the right answer.
60-Second Diagnostic
If something that should be reflecting a file change isn't, compare inodes:
stat -c 'host inode=%i' ./path/on/host
docker compose exec <service> stat -c 'container inode=%i' /path/in/container
If they differ, the container is pinned to an orphaned inode and you've hit this trap.
TL;DR
- Docker single-file bind mounts pin to the inode at mount time.
- Most modern file operations (
git pull, editors,sed -i,cp) produce a new inode, not a modified one. - The container keeps reading the old, orphaned inode. Reloads and restarts don't fix it.
- Mount the parent directory instead. Files are resolved by name on each access, so edits propagate immediately.
Bind the directory, not the file.
Happy Coding!