Reported symptoms
A release adds one operator-supplied file: a per-environment
app.yaml. The Compose change is a single line.
services:
app:
image: registry.example.com/app:2.7.0
volumes:
- /srv/app/config:/app/config:ro
The container starts. Sixty seconds later the application exits:
FATAL: configuration directory /app/config contains no defaults.yaml
defaults.yaml is in the image. It has been in the image for two
years. The image is rebuilt, re-pushed, and rolled back, and the
error does not move.
Diagnosis
Ask the image and the container the same question and compare the answers.
IMAGE=registry.example.com/app:2.7.0
docker run --rm --entrypoint ls "$IMAGE" -la /app/config
docker exec app ls -la /app/config
# from the image
-rw-r--r-- 1 root root 4096 defaults.yaml
-rw-r--r-- 1 root root 812 logging.yaml
drwxr-xr-x 2 root root 4096 templates
# from the running container
total 8
drwxr-xr-x 2 root root 4096 Aug 11 09:14 .
drwxr-xr-x 1 app app 4096 Aug 11 09:14 ..
The image has the files. The container does not. Nothing in between touched the image, so something is covering the directory.
docker inspect app --format '{{json .Mounts}}' | jq
ls -ld /srv/app/config
The host path exists, is empty, is owned by root:root, and its
mtime is the moment of the first deploy. Nobody created it — the
daemon did, because the bind source was missing and dockerd creates
a missing source rather than failing.
Resolution path
- Decide which of the three intents you actually had.
- Add one operator file alongside the image defaults → bind the file, not the directory.
- Replace the whole directory with operator content → seed the host path from the image first, then keep it under configuration management.
- Persist data the container writes → this should be a named volume, not a bind of a populated path.
- Preferred fix: mount the file.
- /srv/app/config/app.yaml:/app/config/app.yaml:roleaves the rest of the directory visible.- Alternative: seed the host directory from the image.
docker run --rm -v /srv/app/config:/out registry.example.com/app:2.7.0 cp -a /app/config/. /out/- The host path now holds the image defaults plus your override — and is now your responsibility to update on every image upgrade.
- Best fix: change the image so this cannot recur.
- Ship defaults in /app/config and read operator overrides from an /etc/app/conf.d that the image creates empty. Nothing is ever shadowed because nothing is ever mounted over a populated path.
- Recreate the container and re-verify.
docker compose up -d --force-recreate app
Verification
- The container sees both sets of files.
docker exec app ls -la /app/configlists the image defaults and the override.- The application starts and serves a request.
- Reproduce the original failure condition. Remove the host source path on a staging host, deploy, and confirm the deploy now fails loudly rather than starting with an empty directory.
- Confirm on a clean host.
docker compose down && docker compose up -dwhere /srv/app/config does not pre-exist.
Prevention
- Treat “dockerd had to create the bind source” as a deploy failure. It is silent by design and it is never what you meant.
- Give every image an explicitly empty override directory. A mount over an empty directory cannot shadow anything.
- Assert the mounted file list in CI: run the image with the real
production mount set and
lsthe directories the application reads. This test costs two seconds and catches the entire class. - Review bind mounts in code review the way you review a
DROP TABLE. A one-line volume entry can remove an entire directory from the application’s view without producing a single log line.