Creating cross-compiled Docker images from Go binaries

I've recently been doing some work to improve the way a couple of Go services have their container images built, so they can be built by humans on either Intel or ARM based machines, and pushed to our container registry which will then be pulled by Intel-based infrastructure.
Additionally, I'm doing some work at the moment that requires pushing ARM-based images (from my Intel-based machine) as they're then pulled by ARM-based infrastructure.
As part of this, I thought I'd write it up as a form of blogumentation, as I've ended up crafting a cross-compiling Dockerfile
for Go apps a few times recently.
Starting point
Let's say we have a super small project:
// main.go
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("Hello from %s on %s\n", runtime.GOOS, runtime.GOARCH)
}
As the most straightforward multi-stage Dockerfile
we could use, we have:
# NOTE that you should be digest pinning!
FROM golang:1.24 AS builder
WORKDIR /app
COPY . .
RUN go build -o hello .
FROM scratch
COPY --from=builder /app/hello /hello
CMD ["/hello"]
(I've skipped straight to multi-stage as they're generally best practice)
If we were to build and run this:
% docker build .
...
=> => exporting layers
=> => writing image sha256:41391bf83a442517408e1ea608e0d58a4f2b17be0d0fc2106e511e83144200b6
% docker run -ti sha256:41391bf83a442517408e1ea608e0d58a4f2b17be0d0fc2106e511e83144200b6
Hello from linux on amd64
Cross-compiled
So how can we go about cross-compiling?
The easiest is using the excellent Goreleaser which has the option to build Docker images.
However, you may want to do this by hand - especially if you're not releasing the binaries for usage elsewhere, but "only" using them as part of your service deployment.
The Go toolchain makes it straightforward to perform the cross-compiling by hand.
We can leverage Docker's official guide to produce the following changes to our Dockerfile
:
# NOTE that you should be digest pinning!
-FROM golang:1.24 AS builder
+FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
+# these will get injected in by Docker
+ARG TARGETOS TARGETARCH
WORKDIR /app
COPY . .
-RUN go build -o hello .
+RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o hello .
FROM scratch
This wires inm GOOS
and GOARCH
, which inform the Go toolchain how to crosscompile.
These are wired in automagically by Docker, as well as ensuring that we're building with the host's architecture (BUILDPLATFORM
).
With these wired in, we can now build this as before:
% docker build .
...
=> => exporting layers
=> => writing image sha256:41391bf83a442517408e1ea608e0d58a4f2b17be0d0fc2106e511e83144200b6
% docker run -ti sha256:41391bf83a442517408e1ea608e0d58a4f2b17be0d0fc2106e511e83144200b6
Hello from linux on amd64
This works as it did before - so how do we then cross-compile? We can use --platform
or DOCKER_DEFAULT_PLATFORM
:
% docker build --platform linux/arm64 .
...
=> => exporting layers 0.0s
=> => writing image sha256:b7f4a79f9e9b8f027410a01415a33f07219113a1aca9897b5e240145219b589d
% docker run -ti sha256:b7f4a79f9e9b8f027410a01415a33f07219113a1aca9897b5e240145219b589d
WARNING: The requested image's platform (linux/arm64) does not match the detected host platform (linux/amd64/v3) and no specific platform was requested
exec /hello: exec format error
This fails expectedly, as I'm not running it on an ARM machine.
Final Dockerfile
# NOTE that you should be digest pinning!
FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
# these will get injected in by Docker
ARG TARGETOS TARGETARCH
WORKDIR /app
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o hello .
FROM scratch
COPY --from=builder /app/hello /hello
CMD ["/hello"]
Better images
As a better option, you'll probably want the final image to be built on top of a "distroless" image, which contains a number of things inbuilt, such as:
- non-
root
user (as the default is thatscratch
runs asroot
, given it doesn't have a concept of users) - TLS certificates for HTTPS/other secure traffic
tzdata
for timezone-aware applications
I'd recommend using Chainguard Images, as they provide a great set of container images, as well as providing a great static
container:
# NOTE that you should be digest pinning!
FROM --platform=$BUILDPLATFORM golang:1.24 AS builder
# these will get injected in by Docker
ARG TARGETOS TARGETARCH
WORKDIR /app
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o hello .
FROM cgr.dev/chainguard/static:latest@sha256:633aabd19a2d1b9d4ccc1f4b704eb5e9d34ce6ad231a4f5b7f7a3af1307fdba8
COPY --from=builder /app/hello /hello
CMD ["/hello"]
Related reading
You may also be interested in: