Multi-platform Go images with Nix

May 30, 2025

Yak shaving can lead one to produce solutions to problems that are perhaps pleasing to only oneself.

But pleasing it is.

For good reasons, Docker—and OCI—is seemingly forever pertinent in the world of software packaging and deployment. Containers provide largely reproducible (FROM ubuntu:latest...!) and layer-cached builds for complex, multi-language full-stack applications to single-binary, statically-linked web servers.

This second type of application is most relevant to the day job and the weekend one-off.

At work—because said static-linked binaries is how we roll—we've almost entirely pivoted to using ko to build and publish our OCI images. ko can perform multi-platform builds via Go's native cross-compilation (GOOS + GOARCH) and simple OCI packaged, all without Docker.

ko is excellent. It uses the go build cache for dependency caching and is incredibly fast.

But I'm in the bad, bad habit of throwing a flake.nix in the root of all my projects.

And conveniently enough, Nix's dockerTools family of packages supports Docker-daemon-free image builds.

So here's a little formula I cooked up for ko-like multi-platform image builds, with a little example of how to orchestrate things via SourceHut Builds and crane.

flake.nix

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs =
    {
      self,
      nixpkgs,
      flake-utils,
      ...
    }:
    flake-utils.lib.eachSystem
      [
        "aarch64-linux"
        "x86_64-linux"
        "aarch64-darwin"
      ]
      (
        system:
        let
          pkgs = import nixpkgs { inherit system; };

          name = "mapenv";
          registry = "docker.io/raylas";
          version = builtins.substring 0 8 self.lastModifiedDate;
          image = "${registry}/${name}:${version}";

          mkBin =
            os: arch:
            pkgs.buildGoModule rec {
              pname = name;
              inherit version;
              src = ./.;
              subPackages = [ "cmd/${pname}" ];
              ldflags = [
                "-s"
                "-w"
              ];
              vendorHash = "<vendor_hash>";
              preBuild = ''
                export GOOS=${os}
                export GOARCH=${arch}
                export CGO_ENABLED=0
              '';
              installPhase = ''
                runHook preInstall
                if [ -d $GOPATH/bin/${os}_${arch} ]; then
                  BINDIR=$GOPATH/bin/${os}_${arch}
                else
                  BINDIR=$GOPATH/bin
                fi
                install -D $BINDIR/${pname} $out/bin/${pname}
                runHook postInstall
              '';
            };

          mkImage =
            arch: bin:
            pkgs.dockerTools.buildLayeredImage {
              name = "${registry}/${name}";
              tag = "${version}-${arch}";
              architecture = arch;
              compressor = "none";
              contents = with pkgs; [
                cacert
                bin
              ];
              config.Entrypoint = [ "/bin/mapenv" ];
            };
        in
        {
          packages = {
            arm64 = mkImage "arm64" (mkBin "linux" "arm64");
            amd64 = mkImage "amd64" (mkBin "linux" "amd64");

            inherit image;
          };

          devShell = pkgs.mkShell {
            buildInputs = with pkgs; [
              go
              gofumpt
              golangci-lint
              gopls
              go-tools

              crane
            ];
          };
        }
      );
}

.build.yaml

image: nixos/unstable
packages:
  - nixos.attic-client
  - nixos.crane
secrets:
  - [...]
environment:
  NIX_CONFIG: "experimental-features = nix-command flakes"
tasks:
  - login: |
      mkdir -p ~/.docker
      crane auth login docker.io --username raylas --password-stdin < ~/.docker-token
  - publish: |
      cd mapenv
      IMAGE=$(nix eval .#image --raw)
      crane push "$(nix build .#arm64 --print-out-paths)" "$IMAGE-arm64"
      crane push "$(nix build .#amd64 --print-out-paths)" "$IMAGE-amd64"
      crane index append \
        -m "$IMAGE-arm64" \
        -m "$IMAGE-amd64" \
        -t "$IMAGE"
      crane tag "$IMAGE" latest

crane is a game changer for OCI manipulation and registry operations. Feels immensely lightweight compared to skopeo.

Then, throw pair of Cachix or Attic tasks in that build definition and take advantage of Nix's incredibly thorough caching model.

Here's the project where I implemented this nonsense. Maybe I'll make a reusable Flake after sitting on this pattern for a bit.