Creating cross-compiled Docker images from Go binaries

Featured image for sharing metadata for article

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 that scratch runs as root, 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"]

You may also be interested in:

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the Apache License 2.0.

#blogumentation #go #docker.

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.