I asked Claude Opus 5 for a Dockerfile and a Compose file for a Spring Boot project. Multi-stage build, dependencies cached in their own layer, a healthcheck on Postgres, the app waiting for the database before starting. I read it, it looked right, I ran it on my Windows machine. It built. It ran. I merged it.
On Mac and Linux, the build died on the first RUN:
/bin/sh: 1: ./mvnw: Permission denied
Here are the lines involved:
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
There is nothing wrong with them. That's the interesting part.
Where the bug actually was
Not in the Dockerfile. In git:
$ git ls-files -s mvnw
100644 bd8896bf2217b46faa0291585e01ac1a3441a958 0 mvnw
100644 means a regular, non-executable file. A script you're meant to run should be 100755. The Maven wrapper had been committed without its executable bit — most likely because the first commit came from Windows, where git doesn't track that bit by default.
On Mac and Linux, checking out the repo gives you an mvnw with mode 644. COPY preserves that mode inside the image. ./mvnw refuses to run.
Why Windows hid it
Windows files don't carry Unix permissions. When you build from a Windows client against a Linux engine, Docker has nothing to copy over, so every file in the build context arrives in the image as rwxr-xr-x. Docker has long printed a warning about exactly this when building from Windows.
So on Windows, mvnw became executable by accident, and the build passed. The one test I ran was structurally incapable of catching the bug.
It wasn't just Docker, either: ./mvnw spring-boot:run failed on Mac and Linux for the same reason. The whole project only worked on Windows.
The red herring
The repo had this .gitattributes:
/mvnw text eol=lf
*.cmd text eol=crlf
It's easy to read that and conclude mvnw is taken care of. It isn't. That rule handles line endings. File mode is a completely separate thing, and nothing in the repo was handling it.
The fix
At the source, so every clone gets it right:
git update-index --chmod=+x mvnw
git commit -m "Make mvnw executable"
And belt and braces in the Dockerfile, so a copy that lost the bit — a downloaded zip, say — still builds:
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
Or skip the question entirely, since sh doesn't need the executable bit:
RUN sh mvnw dependency:go-offline -B
The same applies to gradlew and any shell script you expect people to run.
Check your own repos
git ls-files -s | grep -E '(mvnw|gradlew|\.sh)$'
Anything in that list showing 100644 is waiting for someone on another OS.
This isn't a rare edge case. Out of curiosity I checked another public Spring Boot project from a different author: same wrapper file, byte for byte, same 100644. Its Docker build was fine, because it used the official Maven image instead of the wrapper. Its local build was not.
What I take from it
The generated Dockerfile was correct. The diff was correct. The review, had there been one, would have looked at those lines and approved them. The bug lived in the relationship between the file and the repository's metadata, which a diff doesn't show — and it only appeared on machines the author never used.
When the code in front of you is right, the questions worth asking are about everything around it: which platform was this tested on, what does the file mode say, what happens on the second machine?
I run a small weekly code-review challenge built around bugs like this one, where every line is correct and something is still broken. This was the first challenge of the season: https://www.linkedin.com/feed/update/urn:li:activity:7510232443304714240. Would you have merged it?
Top comments (0)