Operating Systems Bug Report

Container exits with "exec /entrypoint.sh: no such file or directory": CRLF line endings

A worker image built fine but every container exited at once with "no such file or directory" for a file that clearly existed. A Windows checkout had turned the shebang into /bin/bash with a carriage return. We fixed it with .gitattributes and a CI check.

Daniel Tran Daniel Tran @fun_dev · May 22, 2025 · 8 min read · 9 Views
Banner: a shell script shebang line with a hidden carriage return breaking a container start

We run background workers for a logistics platform as containers, each started by a small entrypoint.sh that waits for the database and then launches the worker. A new team member on Windows changed the script to add a health file, opened a pull request, and the CI image built without errors. In the staging cluster every new worker pod went straight to CrashLoopBackOff.

The container log contained one line:

exec /entrypoint.sh: no such file or directory

The file was obviously there. docker run --entrypoint sh into the same image and ls -l showed it, executable, at the expected path. Running it by hand inside the container produced a different and more useful message:

# /entrypoint.sh
sh: 1: /entrypoint.sh: not found
# bash /entrypoint.sh
/entrypoint.sh: line 2: $'\r': command not found
/entrypoint.sh: line 9: syntax error near unexpected token `$'do\r''

And on a Linux laptop, running the checked-out script directly gave the classic version:

bash: ./entrypoint.sh: /bin/bash^M: bad interpreter: No such file or directory

Reproducing it

file and cat -A made the invisible visible:

file docker/entrypoint.sh
docker/entrypoint.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators
head -2 docker/entrypoint.sh | cat -A
#!/bin/bash^M$
set -euo pipefail^M$

Git confirmed the CRLF was in the repository itself, not just in someone's working tree:

git ls-files --eol docker/entrypoint.sh
i/crlf  w/crlf  attr/                   docker/entrypoint.sh

i/crlf means the committed blob has CRLF line endings, and the empty attr/ means no .gitattributes rule applied to the file.

What we ruled out

  • A missing file in the image. docker image inspect and a shell in the container both showed /entrypoint.sh with mode 0755.
  • The wrong base image. The base was debian:bookworm-slim, which has /bin/bash. An Alpine base without bash produces the same message, so it was worth checking.
  • Permissions. A missing execute bit gives permission denied, not no such file or directory.

Root cause

The developer's Git for Windows was configured with core.autocrlf=false and their editor saved the file with Windows line endings. With no .gitattributes in the repository, Git committed the bytes as they were. The first line became #!/bin/bash\r. The kernel reads the shebang literally and tried to run an interpreter named /bin/bash followed by a carriage return, which does not exist, so execve returned ENOENT. Docker reports that as "no such file or directory", naming the script rather than the missing interpreter.

What is happening under the hood

When you execute a file that starts with #!, the Linux kernel parses the rest of that first line up to the newline (\n) as the interpreter path plus an optional argument. A carriage return (\r, shown as ^M) is not a separator, so it becomes part of the path. The lookup fails with ENOENT, and every layer above the kernel reports that error against the file it tried to run:

Who runs the script What you see
bash in a terminal /bin/bash^M: bad interpreter: No such file or directory
Docker or containerd entrypoint exec /entrypoint.sh: no such file or directory
dash (/bin/sh on Debian) sh: 1: /entrypoint.sh: not found
bash script.sh (interpreter given explicitly) Script starts, then $'\r': command not found and syntax errors

Git can translate line endings, but only when it is told what a file is. core.autocrlf is a per-machine setting, so its behaviour depends on how each developer installed Git. A .gitattributes file is committed with the repository and applies to everyone, which is why it should be the source of truth.

Environment

Component Version / value
Developer machine Windows 11, Git for Windows 2.49, core.autocrlf=false
Repository No .gitattributes, no .editorconfig
Image debian:bookworm-slim, ENTRYPOINT ["/entrypoint.sh"]
Runtime containerd on Kubernetes 1.31 (staging), Docker Engine 28 locally

Minimal reproduction

printf '#!/bin/bash\r\necho hello\r\n' > crlf.sh
chmod +x crlf.sh
./crlf.sh
# bash: ./crlf.sh: /bin/bash^M: bad interpreter: No such file or directory
FROM debian:bookworm-slim
COPY crlf.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
docker build -t crlf-demo . && docker run --rm crlf-demo
# exec /entrypoint.sh: no such file or directory

Who is affected

Any repository with shell scripts, Makefiles or other LF-sensitive files and at least one contributor on Windows. It also hits Docker Desktop users with bind mounts: the image can be fine while a mounted working copy with CRLF overrides the script at runtime. Python, Node.js and PHP scripts with a shebang fail the same way when executed directly.

The fix

1. Declare line endings in the repository. We added a .gitattributes at the root:

* text=auto

*.sh        text eol=lf
*.bash      text eol=lf
Dockerfile  text eol=lf
Makefile    text eol=lf
*.env       text eol=lf

*.bat       text eol=crlf
*.cmd       text eol=crlf
*.ps1       text eol=crlf

*.png       binary
*.jpg       binary

text=auto lets Git normalise any file it detects as text to LF in the repository. The explicit eol=lf rules additionally force LF in every working tree, including on Windows, for files that Linux will execute.

2. Renormalise what is already committed. Adding the attributes does not rewrite existing blobs. This command re-stages every tracked file through the new rules:

git add --renormalize .
git status --short
git commit -m "Normalize line endings per .gitattributes"

Developers with existing clones refreshed their working trees once:

git rm --cached -r -q .
git reset --hard

That second step discards uncommitted local changes, so we asked people to commit or stash first.

3. Align editors. An .editorconfig keeps editors from reintroducing CRLF before Git ever sees the file:

root = true

[*]
end_of_line = lf
insert_final_newline = true

[*.{bat,cmd,ps1}]
end_of_line = crlf

Alternatives and trade-offs

  1. Strip carriage returns in the Dockerfile with RUN sed -i 's/\r$//' /entrypoint.sh or dos2unix. It makes the image robust but hides the problem: the same script still breaks in CI jobs and on Linux laptops. We kept it out so the CI check below stays meaningful.
  2. git config --global core.autocrlf input on every Windows machine. Correct, but it depends on each person's setup and does nothing for new contributors.
  3. Call the interpreter explicitly (ENTRYPOINT ["bash", "/entrypoint.sh"]). Avoids the shebang lookup, but bash still chokes on \r inside the script, as the error output above showed.

How to test the fix

git ls-files --eol docker/entrypoint.sh
# i/lf    w/lf    attr/text eol=lf        docker/entrypoint.sh

docker build -t worker:test -f docker/Dockerfile .
docker run --rm worker:test --version

We also tested on a fresh Windows clone with core.autocrlf=true, the Git for Windows installer default, and confirmed w/lf for the script.

Confirming in production

After merging the normalisation commit, staging pods started normally on the first attempt and the CrashLoopBackOff alerts cleared within one rollout. The renormalise commit touched 14 files, four of them shell scripts that had been working only because nobody executed them directly.

A repository-wide check now returns nothing:

git ls-files --eol | grep -E '^i/crlf'

Regression guard

A short CI job fails a pull request if any LF-only file has CRLF in the index:

#!/usr/bin/env bash
set -euo pipefail
bad=$(git ls-files --eol | awk '$1=="i/crlf" && $NF ~ /(\.sh|\.bash|Dockerfile|Makefile)$/ {print $NF}')
if [[ -n "$bad" ]]; then
  echo "CRLF line endings found in:"; echo "$bad"; exit 1
fi

We also added a container smoke test to CI that starts the built image with --version, so an entrypoint that cannot execute fails before deployment rather than in the cluster.

Security implications

None beyond the bug itself; line endings do not change what the script is allowed to do. The workaround people often reach for, a sed that rewrites scripts during the build, is harmless. Be more careful with "fix it in the container" approaches that mount writable scripts from the host into production containers, because those let a host-side change alter what runs as the entrypoint.

Prevention checklist

  • Commit a .gitattributes with eol=lf for every file Linux executes or parses strictly.
  • Run git add --renormalize . once after adding or changing attributes.
  • Add an .editorconfig so editors agree with Git.
  • Fail CI on CRLF in scripts and smoke-test container entrypoints.
  • When you see "no such file or directory" for a file that exists, check the shebang first.

References

Add a label

#docker #git #windows #bash #line-endings
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.