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:
{
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 updateand commit theflake.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:
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 = 0produces a fully static binary — no glibc dependency, runs anywhere.vendorHashlocks your Go module dependencies. Set it tonullif you usego mod vendor, or let Nix tell you the correct hash on first build.ldflagswith-s -wstrips debug info for a smaller binary.
Build it:
$ nix build
$ ./result/bin/myappOr use the included Taskfile.yaml (available via go-task in the dev shell):
$ task buildThe 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:
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:
$ nix build .#docker
$ docker load < result
Loaded image: myapp:latest
$ docker run -p 8080:8080 myapp:latestOr in one step:
$ task docker:runWhy 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-getorapk. 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:
$ 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-dockerOr 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 loadingmain.go— Simple HTTP servergo.mod— Go module definitionTaskfile.yaml— Task runner shortcuts for common commands
Try it instantly — no clone needed:
$ nix run git+https://codeberg.org/getnix/go-nix-dockerNix fetches the repo, builds the binary, and starts the HTTP server. In another 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/amd64To explore the source and develop locally, clone it:
$ 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 imageQuick reference
| Command | What it does |
|---|---|
nix run git+https://codeberg.org/getnix/go-nix-docker | Run the app without cloning |
direnv allow | Auto-load dev shell on cd |
nix develop | Enter dev shell manually |
task run | Run the app locally |
task build | Build the Go binary reproducibly |
task docker:build | Build the Docker image |
task docker:load | Build and load image into Docker |
task docker:run | Build, load, and run the container |
task verify | Compare sha256 of local and Docker binary |
task lint | Run golangci-lint |
task clean | Remove build artifacts, containers, and images |
task update | Update flake inputs and regenerate flake.lock |