COPY/ADD can't find the file because Docker resolves paths against the build context, not the Dockerfile's directory or your shell's cwd. And .dockerignore can hide files you meant to copy.
docker build . — the '.' is the context; all COPY paths are relative to it. Building from repo root while the Dockerfile expects to be in ./app/ makes every path miss. build path from CI checkouts differs for the same reason.
Over-broad patterns (e.g. '*' plus narrow un-ignores) silently drop the file from the context. COPY then can't see it — even though ls shows it right there.
docker build --no-cache --progress=plain -t debug-ctx . 2>&1 | grep -i 'transferring\|COPY' | head
cat .dockerignore 2>/dev/null | grep -v '^#' ; # test: tar -cf - . -X .dockerignore | tar -tf - | grep <file>
docker build -f docker/app.Dockerfile . # context = repo root; COPY app/... paths match
ls -la <expected-context>/ ; pwd # align 'docker build' invocation between local and CI
COPY can't reach outside the context — '../file' is a build error by design. If you need it, widen the context and adjust paths. Compose: the build: context is per-service in compose.yaml — check that, not the CLI default.
The build context is a security boundary: nothing outside it enters the image. Move the Dockerfile's context up (and adjust paths) instead of reaching out.
Check .dockerignore (the silent filter) and confirm the context root: run docker build with --progress=plain and read the 'transferring context' size — a suspiciously small transfer means your files never entered the context.
A production-shaped compose stack: healthchecks, resource limits, log rotation. Never debug a boot race again.
Docker Production Starter — $19 →One-time. Yours to modify. Instant download from the NinjaOps template store.