Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang
Hi, it's BlueByte. A container that ran fine on your laptop dies on the server the moment it starts, and the only line in the log is exec /docker-entrypoint.sh: exec format error. Nothing in your code is wrong and the image is not corrupt: the kernel on the host was handed a file it cannot execute. Almost always that is an image built for the wrong CPU architecture — an Apple-silicon Mac produced an arm64 image and an x86_64 server tried to run it — and sometimes it is a script that lost its first line. We'll walk through what the kernel is really saying, the three things that produce it, the commands that tell them apart, the fix for each, and how to keep it out of your pipeline.
What the kernel is saying with "exec format error"
The message is the text of ENOEXEC, the error execve() returns when the file exists and is executable but the kernel has no idea how to run it. Two shapes of file trigger it: an ELF binary compiled for a different architecture than the CPU (an aarch64 binary on an x86_64 host, or the reverse) when no emulator is registered, and a text file that does not start with a #! shebang, so there is no interpreter to hand it to. Docker prints it in a few forms depending on the engine version:
exec /docker-entrypoint.sh: exec format error
exec /app/server: exec format error
standard_init_linux.go:228: exec user process caused: exec format error
The first two are current runc; the third is what older engines print, and the line number varies. In Kubernetes the pod goes to CrashLoopBackOff, kubectl logs shows the same one-liner, and kubectl describe pod carries it inside a longer OCI runtime create failed: … exec format error event. Often a warning came first, and it is the best clue you will get:
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
The three things that produce it
The image was built for another architecture. docker build produces an image for the machine it runs on unless you say otherwise. A build on an M-series Mac is linux/arm64; a build on an Intel laptop or a GitHub-hosted runner is linux/amd64. Push that to a registry, deploy it to the other kind of host, and the entrypoint binary is a foreign ELF file.
The host has no emulator registered. Docker Desktop ships QEMU, so an amd64 image starts on an arm64 Mac (slowly). A plain Linux engine has nothing registered in binfmt_misc by default, so the same image fails there. This is why "it runs on my Mac" and "it dies on the server" are both true.
The entrypoint is a script with no shebang. With the exec form ENTRYPOINT ["/docker-entrypoint.sh"] Docker calls execve() directly; there is no shell to fall back on. A script whose first line is not #!/bin/sh (or similar) is just bytes to the kernel. On your laptop the same file works because bash, when execve fails with ENOEXEC, quietly runs it as a shell script itself.
First, compare the image's platform with the host's
Run these on the host that fails:
uname -m
docker version --format '{{.Server.Os}}/{{.Server.Arch}}'
docker image inspect --format '{{.Os}}/{{.Architecture}}' myorg/app:1.4If you check it yourself on an arm64 host you get aarch64, linux/arm64, and then the image's platform — when the third line says linux/amd64, that is your cause. To see what the registry actually holds, ask it directly:
docker buildx imagetools inspect myorg/app:1.4A single-platform image prints one Platform: linux/amd64 line. A multi-platform image prints a Manifests: block with one entry per platform (linux/amd64, linux/arm64/v8, …), and Docker picks the matching one at pull time. Then check whether the host can emulate at all:
ls /proc/sys/fs/binfmt_misc/On our arm64 server this lists only python3.12 register status — no qemu-* entries, so an amd64 image has no chance here. If the platforms already match, look at the entrypoint's first bytes instead:
head -c 16 docker-entrypoint.sh | od -cYou want # ! / b i n / s h \n. If the output starts with your first command instead, the shebang is missing.
Fix 1: build the image for the platform that runs it
Tell BuildKit the target explicitly, and push both when your fleet is mixed:
docker buildx build --platform linux/amd64 -t myorg/app:1.4 --push .
docker buildx build --platform linux/amd64,linux/arm64 -t myorg/app:1.4 --push .Building a foreign platform needs either emulation or cross-compilation. On a Linux CI runner without Docker Desktop, register QEMU once — this is a privileged one-shot container, and all it does is write binfmt_misc entries, so nothing else on the host changes:
docker run --privileged --rm tonistiigi/binfmt --install allFor compiled languages, cross-compiling is faster than running the whole toolchain under emulation. Docker's automatic build arguments let one Dockerfile serve every target:
FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/server .
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]The build stage runs natively on the builder; only the output is for the target.
Fix 2: run a foreign image on purpose
Sometimes the image is only published for amd64 and you are on an arm64 laptop. With QEMU registered, ask for it explicitly and accept the speed penalty:
docker run --rm --platform linux/amd64 myorg/app:1.4The same flag exists in a Dockerfile (FROM --platform=linux/amd64 …) and in Compose (platform: linux/amd64 on the service), and DOCKER_DEFAULT_PLATFORM=linux/amd64 in the environment makes it the default for every command. For Kubernetes, pin the workload to nodes that can run it instead of hoping:
nodeSelector:
kubernetes.io/arch: amd64Fix 3: give the entrypoint a shebang
Add #!/bin/sh as the very first line — no blank line above it — and rebuild, or name the interpreter in the exec form so the kernel never has to guess: ENTRYPOINT ["sh", "/docker-entrypoint.sh"].
A real case: a Mac build, an x86 server, a Friday deploy
A developer built a hotfix on an M2 MacBook with plain docker build, pushed registry/app:1.4, and the Ubuntu VM restarted the container into a crash loop with exec /app/server: exec format error. uname -m on the VM said x86_64; docker image inspect on the tag said linux/arm64. The fix was one rebuild from the same commit with --platform linux/amd64,linux/arm64 --push; imagetools inspect then listed both manifests, the VM pulled the amd64 one, and the container stayed up.
Verify it, and keep the mismatch out of CI
On the host, docker run --rm myorg/app:1.4 uname -m should print the host's own architecture, and docker ps should show Up, not Restarting. Then make the build explicit for good: always pass --platform in CI rather than relying on the runner's CPU, publish manifest lists if you run mixed nodes, and add docker buildx imagetools inspect as a release check. For scripts, keep *.sh text eol=lf in .gitattributes so a Windows checkout cannot alter the first line.
Errors that sit next door but mean something else
exec /docker-entrypoint.sh: no such file or directory when the file plainly exists is the interpreter that is missing — #!/bin/bash on an Alpine image, or a shebang that ends in \r because the file was saved with Windows line endings (we reproduced this: #!/bin/sh\r\n returns ENOENT, not ENOEXEC). permission denied is a script without the execute bit. And the WARNING: The requested image's platform … line alone is not fatal: with QEMU present the container runs, just slower.
Next time a container dies on its first instruction, walk these checks back in order: uname -m, docker image inspect, binfmt_misc, then the first sixteen bytes of the entrypoint — and fix the build, not the server.
Related questions
The image runs on my Mac but dies on the server with exec format error. Why the difference?
Docker Desktop registers QEMU, so a foreign-architecture image starts on the Mac under emulation. A plain Linux engine has no qemu-* entries in /proc/sys/fs/binfmt_misc by default, so the same image fails there. Build for the server's platform with --platform, or publish a manifest list that covers both.
Is it fine to just add --platform linux/amd64 on my arm64 laptop and move on?
For local testing, yes — with QEMU registered the container runs, only slower, and Docker prints the platform-mismatch warning. For anything you deploy, build natively for the target or cross-compile; emulation is a convenience, not a production setting.
Does Kubernetes pick the right architecture automatically?
Only when the tag is a manifest list: the node's container runtime pulls the entry that matches its own platform. A single-platform image is pulled as-is on every node, so on a mixed cluster either publish both platforms or pin the workload with a nodeSelector on kubernetes.io/arch.
My entrypoint script has no shebang and works on my laptop. Why not in the container?
On your laptop you run it from bash, which catches the kernel's ENOEXEC and executes the file as a shell script itself. The exec form of ENTRYPOINT calls execve() directly with no shell to fall back on, so the kernel's error reaches you. Add #!/bin/sh as the first line or use ENTRYPOINT ["sh", "/docker-entrypoint.sh"].
QEMU or cross-compilation for multi-platform builds?
Docker's multi-platform docs list three strategies: emulation with QEMU (the easiest start), multiple native builder nodes, and cross-compilation with BUILDPLATFORM/TARGETARCH. Emulation runs every build step under QEMU, so for compiled languages a FROM --platform=$BUILDPLATFORM stage that cross-compiles is both faster and simpler to keep in CI.
References
- Docker Docs — Multi-platform builds (docker buildx build --platform, QEMU via tonistiigi/binfmt --install all, cross-compilation with BUILDPLATFORM/TARGETOS/TARGETARCH, manifest list selection at pull time)
- Docker Docs — docker buildx imagetools inspect (Manifests block with one Platform line per architecture)
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
More in this category
Git: fatal: detected dubious ownership in repository
Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.
Kubernetes: Internal error occurred: failed calling webhook
An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.
Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)
The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.
curl: (60) SSL certificate problem: unable to get local issuer certificate
curl walked the certificate chain the server sent and reached a certificate whose issuer is not in the CA store it is reading, so it refused the connection with exit code 60. The issuer is missing for one of four reasons: the server sends only its leaf certificate without the intermediate (browsers hide this by fetching it themselves), a TLS-inspecting proxy re-signed the site with a company CA that the container or runner does not trust, curl is reading a different CA bundle than you think (CURL_CA_BUNDLE, SSL_CERT_FILE, a vendored curl), or the ca-certificates package is too old. curl -v shows which store was used and openssl s_client shows what the server sent; fix the side that is missing the link and confirm with -w '%{ssl_verify_result}'.