getnix.io
Go Nix Docker 6 min read

Reproducible Go with Nix & Docker

Set up a Nix development environment for Go and build a Docker image with a byte-by-byte identical binary in development and production.

Why reproducible builds?

Most Go projects rely on a patchwork of version managers, Dockerfiles, and CI scripts to keep environments consistent. Inevitably, the binary you test locally drifts from what runs in production — different compiler versions, different C libraries, different flags.

Nix solves this at the root. A single flake.nix declares every dependency with a cryptographic hash. The same inputs always produce the same outputs, bit-for-bit. Your development binary is your production binary.

Step 1: Development environment

Start with a flake.nix that gives every developer the same Go toolchain:

nix
{
  description = "Go project";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";

  outputs = { nixpkgs, ... }:
  let
    systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
    forAllSystems = nixpkgs.lib.genAttrs systems;
  in {
    devShells = forAllSystems (system:
    let
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      default = pkgs.mkShell {
        packages = with pkgs; [
          go
          gopls
          golangci-lint
          delve
          go-task
        ];
      };
    });
  };
}

Run nix develop and you get a shell with the exact same Go version, language server, linter, debugger, and Task runner on every machine. No goenv, no asdf, no “works on my machine.”

With direnv and a one-line .envrc (use flake), the dev shell loads automatically when you cd into the project — no need to run nix develop manually.

Pin your flake inputs with nix flake update and commit the flake.lock. This locks every dependency to a specific revision.

Step 2: Build the Go binary with Nix

Add a packages output using buildGoModule. This compiles your Go project inside the Nix sandbox — no ambient state and fully reproducible:

nix
packages = forAllSystems (system:
let
  pkgs = nixpkgs.legacyPackages.${system};
in {
  default = pkgs.buildGoModule {
    pname = "myapp";
    version = "0.1.0";
    src = ./.;
    vendorHash = null; # use `go mod vendor` or set the hash

    env.CGO_ENABLED = 0;

    ldflags = [
      "-s" "-w"
      "-extldflags '-static'"
    ];
  };
});

Key points:

  • env.CGO_ENABLED = 0 produces a fully static binary — no glibc dependency, runs anywhere.
  • vendorHash locks your Go module dependencies. Set it to null if you use go mod vendor, or let Nix tell you the correct hash on first build.
  • ldflags with -s -w strips debug info for a smaller binary.

Build it:

terminal
$ nix build
$ ./result/bin/myapp

Or use the included Taskfile.yaml (available via go-task in the dev shell):

terminal
$ task build

The binary in ./result is the Nix store path. Build it again tomorrow, on another machine, in CI — you get the same bytes.

Step 3: Docker image with Nix

Instead of writing a Dockerfile, use Nix’s dockerTools to build a minimal OCI image that contains only your binary. Docker containers run Linux, so the flake builds a separate Linux binary for the image — on Linux this is identical to the native binary, on macOS it cross-compiles automatically:

nix
packages = forAllSystems (system:
let
  pkgs = nixpkgs.legacyPackages.${system};

  goArch = {
    "x86_64-linux"   = "amd64";
    "aarch64-linux"  = "arm64";
    "aarch64-darwin" = "arm64";
  }.${system};

  isLinux = builtins.match ".*-linux" system != null;

  myapp = pkgs.buildGoModule {
    pname = "myapp";
    version = "0.1.0";
    src = ./.;
    vendorHash = null;

    env.CGO_ENABLED = 0;

    ldflags = [
      "-s" "-w"
      "-extldflags '-static'"
    ];
  };

  # On Linux, reuse the native binary. On macOS, cross-compile for Linux.
  myapp-linux = if isLinux then myapp else pkgs.buildGoModule {
    pname = "myapp";
    version = "0.1.0";
    src = ./.;
    vendorHash = null;

    env.CGO_ENABLED = 0;

    preBuild = ''
      export GOOS=linux
      export GOARCH=${goArch}
    '';

    # Go puts cross-compiled binaries in bin/GOOS_GOARCH/ — flatten it
    postInstall = ''
      if [ -d "$out/bin/linux_${goArch}" ]; then
        mv "$out/bin/linux_${goArch}/"* "$out/bin/"
        rmdir "$out/bin/linux_${goArch}"
      fi
    '';

    ldflags = [
      "-s" "-w"
      "-extldflags '-static'"
    ];
  };
in {
  default = myapp;

  docker = pkgs.dockerTools.buildLayeredImage {
    name = "myapp";
    tag = "latest";
    contents = [ myapp-linux ];
    config = {
      Cmd = [ "/bin/myapp" ];
      ExposedPorts."8080/tcp" = {};
    };
  };
});

Build and load it:

terminal
$ nix build .#docker
$ docker load < result
Loaded image: myapp:latest

$ docker run -p 8080:8080 myapp:latest

Or in one step:

terminal
$ task docker:run

Why buildLayeredImage?

  • Minimal — No base image, no shell, no package manager. Only your binary and what you explicitly include.
  • Layered — Nix store paths become individual Docker layers. Unchanged dependencies are cached between builds.
  • Deterministic — The image is built from the Nix store, not from apt-get or apk. Same inputs, same image.

The result: byte-by-byte identical

On Linux, myapp-linux is myapp — Nix reuses the same derivation. The binary you test locally with nix build is the exact same binary inside the Docker image. Since the image is minimal (no shell, no coreutils), copy the binary out to compare:

terminal
$ nix build
$ sha256sum ./result/bin/myapp
a1b2c3d4...  ./result/bin/myapp

$ nix build .#docker
$ docker load < result
$ docker create --name tmp myapp:latest
$ docker cp tmp:/bin/myapp /tmp/myapp-from-docker
$ docker rm tmp
$ sha256sum /tmp/myapp-from-docker
a1b2c3d4...  /tmp/myapp-from-docker

Or run task verify to do this automatically.

Same hash. The binary in your dev environment, your CI pipeline, and your production container are identical — not “close enough,” not “built from the same source,” but the same bytes.

On macOS, the Docker image contains a cross-compiled Linux binary while your local nix build produces a native macOS binary. The Linux binary inside Docker is still fully reproducible — build it on any machine with the same flake.lock and you get the same bytes.

This eliminates an entire class of bugs: “it worked in dev but not in prod.”

Complete example

The full working example is at codeberg.org/getnix/go-nix-docker with all files needed to build and run:

  • flake.nix — Dev shell + Go build + Docker image
  • .envrc — direnv config for automatic shell loading
  • main.go — Simple HTTP server
  • go.mod — Go module definition
  • Taskfile.yaml — Task runner shortcuts for common commands

Try it instantly — no clone needed:

terminal
$ nix run git+https://codeberg.org/getnix/go-nix-docker

Nix fetches the repo, builds the binary, and starts the HTTP server. In another terminal:

terminal
$ curl -i http://127.0.0.1:8080
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Hello from myapp

Go go1.25.7 linux/amd64

To explore the source and develop locally, clone it:

terminal
$ git clone https://codeberg.org/getnix/go-nix-docker.git
$ cd go-nix-docker
$ direnv allow       # auto-load dev shell (or: nix develop)
$ go run .           # run locally

$ nix build          # build reproducible binary
$ nix build .#docker # build Docker image

Quick reference

CommandWhat it does
nix run git+https://codeberg.org/getnix/go-nix-dockerRun the app without cloning
direnv allowAuto-load dev shell on cd
nix developEnter dev shell manually
task runRun the app locally
task buildBuild the Go binary reproducibly
task docker:buildBuild the Docker image
task docker:loadBuild and load image into Docker
task docker:runBuild, load, and run the container
task verifyCompare sha256 of local and Docker binary
task lintRun golangci-lint
task cleanRemove build artifacts, containers, and images
task updateUpdate flake inputs and regenerate flake.lock