The First Shock: “exec format error”
Many developers on Apple Silicon M-series MacBooks have likely faced this exact scenario: the code runs flawlessly on local, and running docker build followed by pushing to Docker Hub goes off without a hitch.
The trouble only begins once you SSH into your Ubuntu VPS (such as a 2 vCPU DigitalOcean droplet or an AWS x86 EC2 instance). Eager to deploy, you pull the image and hit docker run—only to watch the container crash instantly with a blunt, cryptic log:
standard_init_linux.go:228: exec user process caused: exec format error
In a panic, you double-check your code, run chmod 777 on your startup scripts, or comb through your Dockerfile searching for syntax errors. Everything looks spotless. In reality, your code isn’t broken at all. The real culprit is an underlying CPU architecture mismatch between the two machines.
Behind the Scenes: What Actually Happened?
By default, the standard docker build command compiles your application binaries targeting only the instruction set architecture of the machine you are currently typing on:
- Apple Silicon MacBooks (M1 through M4): Built on
linux/arm64. - Most standard Cloud VPS instances (Intel Xeon, AMD EPYC): Powered by
linux/amd64(x86_64).
ARM machine code is complete gibberish to an x86_64 CPU. When the Linux kernel on the server attempts to load the ARM binary into memory, it cannot parse the instruction format. As a result, the kernel immediately throws an exec format error and pulls the plug on the container.
Two Workarounds and Their Painful Trade-offs
1. Building directly on the target server via CI/CD runners
Instead of building locally, you push your code to Git and let a GitHub Actions or GitLab Runner hosted on an x86 server handle the build. While this solves the environment mismatch, it comes at a steep price.
Building a Node.js or Golang image can easily max out 100% of the CPU on a production 2 vCPU VPS, causing server lag and stretching build times to 7–10 minutes. Worse yet, if your team later migrates to AWS Graviton (switching to ARM64 chips to cut costs by 20%), you’ll have to rewrite your entire CI/CD setup from scratch.
2. Enforcing architecture with the –platform flag
Docker Desktop allows you to force a specific target architecture using QEMU emulation:
docker build --platform linux/amd64 -t my-app:latest .
Your Mac will now emulate an x86 environment to generate the exact image your server requires. However, this is still merely a band-aid solution.
This command only generates a single-architecture image. Once pushed to Docker Hub, the AMD64 build will overwrite the ARM64 one. When another teammate pulls that image onto their Mac for local testing, their container will run sluggishly under QEMU emulation—often introducing puzzling cache-related quirks.
The Proper Solution: Using Docker Buildx
Docker Buildx is a modern CLI plugin powered by the BuildKit engine. It enables you to compile and package both linux/amd64 and linux/arm64 targets concurrently with a single command.
The beauty of this approach lies in the Manifest List (a unified OCI index published under a single tag). When you pull the image on your Mac, the Docker daemon automatically pulls the ARM64 variant. When your x86 Linux server pulls the exact same tag, Docker fetches the AMD64 binary. Both environments work seamlessly without manual intervention.
Step 1: Prepare the Environment and QEMU
If you are running Docker Desktop on macOS or Windows, Buildx is pre-installed and ready out of the box. You can verify it with:
docker buildx version
If you are configuring a Linux server (Ubuntu/Debian) acting as a dedicated runner, register the QEMU binfmt modules so the operating system can handle cross-architecture emulation:
docker run --privileged --rm tonistiigi/binfmt --install all
Step 2: Initialize a New Builder Instance
The default builder tied to the legacy Docker daemon cannot publish manifest lists directly to remote registries. We need a dedicated builder instance powered by the container driver:
# Create a new builder named multi-builder
docker buildx create --name multi-builder --driver docker-container --bootstrap --use
# Inspect the list of supported platforms
docker buildx inspect --bootstrap
Your terminal will print an extensive list of supported platforms, including: linux/amd64, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6.
Step 3: Optimize Your Dockerfile for Multi-Architecture
Ensure that the base images specified in your Dockerfile support multi-arch builds. Fortunately, standard base images like node:alpine, python:slim, and golang:alpine support multi-architecture out of the box.
# Standard multi-arch Dockerfile example for a Node.js app
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Step 4: Build and Push Directly to the Registry
Local Docker image stores cannot yet hold multi-platform manifest lists under a single tag. Therefore, Buildx requires passing the --push flag to ship the built artifacts directly to your registry:
# Log in to your registry first
docker login
# Build amd64 and arm64 concurrently, then push immediately
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t username/my-app:1.0.0 \
-t username/my-app:latest \
--push .
Practical Tip: Because cross-compiling architectures relies on QEMU emulation, builds can take about 1.5x to 2x longer than usual. Please be patient while the process completes.
Step 5: Verify the Manifest on Docker Hub
Rather than taking it on faith, verify the structure of the published image using the following command:
docker buildx imagetools inspect username/my-app:latest
The CLI will return a JSON payload detailing the manifest list. To inspect the JSON payload more comfortably, you can paste it into an online formatter such as the json-formatter at toolcraft.app/en/tools/developer/json-formatter. Expanding the objects will reveal two distinct blocks: one for architecture: amd64 and one for architecture: arm64.
From this point onward, whether you run the command on a development Mac or a production server:
docker run -d -p 3000:3000 username/my-app:latest
Each host will seamlessly download the exact binary tailored to its underlying CPU. The notorious exec format error is officially a thing of the past.