Exploring Nix Flakes: Usable Go Plugins

Part 3: Targets and Releases

What we did until now assumed that the target system has Nix with Flake support available. This will not always be the case. In order for our Flake-based plugin management to be viable as a general solution, it must also be able to target Nix-less environments. Therefore, we will now explore how to compile our application for deployment in a Nix-less environment.

The most critical target platform is Windows, since it is not supported by Nix as host system. Our goal will be to produce a native Windows binary. Since it is possible to run NixOS on WSL, Windows folks will be able to build the application on Windows for Windows, with the fine print of doing it via cross-compilation on WSL.

A second target platform we will discuss is the Raspberry Pi 4. While this is not wholly unsupported by Nix, the support is beta-grade at best. Also, the Pi is simply not a very fast machine, so we might want to cross-compile for it even if we could compile natively simply to achieve faster build times.

The third target platform will be OCI. Like it or not, containers are widely employed as solution for easy deployment, and Go is a common language for writing web services that are deployed via container. Therefore, we will explore how to build a container image with Nix.

Setup, Again

We will use the same Go code we used for part 2. Simply copy the whole directory image-server to image-server-cross to get started. The new directory is to set the updated code apart in this article’s repository.

We will rewrite the flake.nix completely, and I will show its new content bit by bit to discuss what we’re doing. You can fetch the file’s complete content from the repository.

Cross Compiling

Nix has support for cross-compilation. This would provide us with a cross-compiling GCC that could compile our C code and all its dependencies. Together with the standard Go compiler, which can already cross-compile Go code, we’d have a complete toolchain. However, this would mean that we would need to build a cross-compiling GCC and cross-compile the cairo library since there are no binary caches for that. That reeks of unnecessary complexity (and is also experimental: I wasn’t able to get it to work on aarch64-darwin).

Thankfully, there is an alternative: Zig. Zig is a language with a compiler that happens to bundle enough of clang and llvm that it can basically cross-compile C almost everywhere. And using Zig to cross-compile Go has already been explored. So this is what we’ll be doing.

Without further ado, let’s start writing image-server-cross/flake.nix:

  description = "demo image server";
  inputs = {
    nixpkgs.url = github:NixOS/nixpkgs/nixos-21.11;
    utils.url = github:numtide/flake-utils;
    nix-filter.url = github:numtide/nix-filter;
    api.url = path:../api;
    zig.url = "github:arqv/zig-overlay";
    zig.inputs.nixpkgs.follows = "nixpkgs";
    go-1-18.url = github:flyx/go-1.18-nix;

We include Zig from a Flake instead of from nixpkgs to have the latest 0.9.1 version. We also include Go 1.18, which has not yet been released but is available as beta version. Go 1.18 fixes an vital issue with cross-compiling for Windows.

Beta software is not for production use, especially not compilers! I will update this article with the proper Go 1.18 version once it is out. I created the go-1.18 overlay primarily for this article. The first Flake build will take some time since it needs to build Go 1.18 (it is not available in the binary cache).

  outputs = {self, nixpkgs, utils, nix-filter, api, zig, go-1-18 }:
    go118pkgs = system: import nixpkgs {
      inherit system;
      overlays = [ go-1-18.overlay ];
    platforms = system: let
      pkgs = go118pkgs system;
      zigPkg = zig.packages.${system}."0.9.1".overrideAttrs (old: {
        installPhase = let
          armFeatures = builtins.fetchurl {
            url = "https://sourceware.org/git/?p=glibc.git;"    +
                  "a=blob_plain;f=sysdeps/arm/arm-features.h;"  +
                  "h=80a1e2272b5b4ee0976a410317341b5ee601b794;" +
            name = "glibc-2.35_arm-features.h";
            sha256 =
        in old.installPhase + "\ncp ${armFeatures} " +
      zigScript = target: command: ''
        ${zigPkg}/bin/zig ${command} -target ${target} $@
      zigScripts = target: pkgs.stdenvNoCC.mkDerivation {
        name = "zig-cc-scripts";
        phases = [ "buildPhase" "installPhase" ];
        propagatedBuildInputs = [ zigPkg ];
        ZCC = zigScript target "cc";
        ZXX = zigScript target "c++";
        buildPhase = ''
          printenv ZCC >zcc
          printenv ZXX >zxx
          chmod u+x zcc zxx
        installPhase = ''
          mkdir -p $out/bin
          cp zcc zxx $out/bin

go118pkgs is a function that, given a system, instantiates nixpkgs with the given system and our Go 1.18 overlay.

platforms is a function that, given a system, shall give us a set of configurations for all target platforms we want to cross-compile to. A configuration will be a function that overrides the necessary parts of our buildGoModule invocation.

In zigPkg I modify the original package by including a glibc header file that is missing in the current release. We’ll need it to target the Raspberry Pi.

zigScripts creates a derivation that contains the scripts zcc and zxx which are wrappers that call zig’s bundled, cross-compiling clang (as described in the article mentioned above). This derivation depends on the target parameter, which is a Target Triplet that tells Zig about our target system.

This concludes our Zig setup.

C Dependencies

I already said that I don’t want to cross-compile all C dependencies. So what should we do instead? If our target was supported by nixpkgs, we could theoretically pull our dependencies from the binary cache; however this won’t work for Windows. However, the dependencies are packaged for our target systems – just not with Nix.

Our course of action is therefore to just pull the dependencies from their native package repositories, which will be good enough for linking against them. For Windows, the package repository we’ll use is MSYS2, which uses pacman. For the Raspberry Pi, it will be the repository of Raspberry Pi OS (no fancy web UI available apparently), which is mostly just debian, and thus uses dpkg.

Let’s write functions for those two package managers that we can use to pull packages from their repositories:

      fromDebs = name: debSources: pkgs.stdenvNoCC.mkDerivation rec {
        inherit name;
        srcs = with builtins; map fetchurl debSources;
        phases = [ "unpackPhase" "installPhase" ];
        nativeBuildInputs = [ pkgs.dpkg ];
        unpackPhase = builtins.concatStringsSep "\n" (builtins.map
          (src: "${pkgs.dpkg}/bin/dpkg-deb -x ${src} .") srcs);
        installPhase = ''
          mkdir -p $out
          cp -r * $out
      fromPacman = name: pmSources: pkgs.stdenvNoCC.mkDerivation rec {
        inherit name;
        srcs = with builtins; map fetchurl pmSources;
        phases = [ "unpackPhase" "installPhase" ];
        nativeBuildInputs = [ pkgs.gnutar pkgs.zstd ];
        unpackPhase = builtins.concatStringsSep "\n" (builtins.map
          (src: ''
            ${pkgs.gnutar}/bin/tar -xvpf ${src} --exclude .PKGINFO \
              --exclude .INSTALL --exclude .MTREE --exclude .BUILDINFO
          '') srcs);
        installPhase = ''
          mkdir -p $out
          cp -r -t $out *

Each function takes a name, that will be the name of the generated derivation, and a list of sources, which are inputs to fetchurl. The derivations fetch their sources, unpack them, and write the result to their store path. Pretty straightforward as long as you can figure out those tar parameters.

Configuring Go

The last bit we need for our cross-compilation is the configuration for the Go compiler. This is a bit tricky: Go modules that wrap C files tend to use pkg-config to query their C compilation and link flags. go-cairo is a module that does this. The foreign packages we will fetch do come with pkg-config descriptions, but they assume a normal installation of the package into /, which we do not do. The path of least resistance for us is thus to disable retrieval of parameters via pkg-config and instead just supply the C flags manually.

      platformConfig = {
        target, cairo, CGO_CPPFLAGS, CGO_LDFLAGS, GOOS, GOARCH,
        overrides ? _: {}
      }: let
        zigScriptsInst = zigScripts target;
      in {
        targetPkgs.cairo = cairo;
        buildGoModuleOverrides = old: let
          basic = {
            CGO_ENABLED = true;
            inherit CGO_CPPFLAGS CGO_LDFLAGS;
            preBuild = ''
              export ZIG_LOCAL_CACHE_DIR=$(pwd)/zig-cache
              export CC="${zigScriptsInst}/bin/zcc"
              export CXX="${zigScriptsInst}/bin/zxx"
              export GOOS=${GOOS}
              export GOARCH=${GOARCH}
            overrideModAttrs = _: {
              postBuild = ''
                patch -p0 <${./cairo.go.patch}
                patch -p0 <${./png.go.patch}
        in basic // (overrides (old // basic));

platformConfig defines the general framework shared by our target platforms. What we do here is:


--- vendor/github.com/ungerik/go-cairo/cairo.go
+++ vendor/github.com/ungerik/go-cairo/cairo.go
@@ -2,7 +2,6 @@
 package cairo
-// #cgo pkg-config: cairo
 // #include <cairo/cairo-pdf.h>
 // #include <cairo/cairo-ps.h>
 // #include <cairo/cairo-svg.h>


--- vendor/github.com/ungerik/go-cairo/png.go
+++ vendor/github.com/ungerik/go-cairo/png.go
@@ -3,7 +3,6 @@
 package cairo

 // #cgo CFLAGS: -Wall -O2
-// #cgo pkg-config: cairo
 // #include <stdio.h>
 // #include <stdlib.h>
 // #include <string.h>

Nothing spectacular here, we simply remove the lines instructing cgo to call pkg-config.

Back to our flake.nix. We are now ready to define our first target platform:

      msysPrefix = "https://mirror.msys2.org/mingw/clang64/" +
      rpiPrefix = "http://archive.raspberrypi.org/debian/pool/" +
    in {
      raspberryPi4 = platformConfig rec {
        target = "arm-linux-gnueabihf";
        cairo = fromDebs "cairo" [{
          url = "${rpiPrefix}/libcairo2-dev_1.16.0-5+rpt1_armhf.deb";
          sha256 =
        } {
          url = "${rpiPrefix}/libcairo2_1.16.0-5+rpt1_armhf.deb";
          sha256 =
        CGO_CPPFLAGS = "-I${cairo}/usr/include/cairo " +
        CGO_LDFLAGS = "-L${cairo}/usr/lib/arm-linux-gnueabihf -lcairo";
        GOOS = "linux";
        GOARCH = "arm";

This is the platform for the Raspberry Pi 4. Since the packages there are debian-based, cairo is split into a main package and a dev package, which we need both to be able to link against it. Therefore, we fetch both packages with our helper function, which creates our cairo derivation from those two inputs. Our library files in this case are inside lib/arm-linux-gnueabihf so we need to set up CGO_LDFLAGS accordingly.

      win64 = platformConfig rec {
        target = "x86_64-windows-gnu";
        cairo = let
          depLines = with nixpkgs.lib;
            splitString "\n" (fileContents ./win64-deps.txt);
          deps = with builtins; map (line: let
            parts = elemAt (nixpkgs.lib.splitString " " line);
          in { url = "${msysPrefix}${parts 1}";
               sha256 = parts 0; }) depLines;
        in fromPacman "image-server-win64-deps" deps;
        CGO_CPPFLAGS = "-I${cairo}/clang64/include/cairo " +
        CGO_LDFLAGS = "-L${cairo}/clang64/lib -lcairo";
        GOOS = "windows";
        GOARCH = "amd64";
        overrides = _: {
          postInstall = ''
            cp -t $out/bin/windows_amd64 ${cairo}/clang64/bin/*.dll

This is our platform for Windows. Zig calls the CPU architecture x86_64 while Go calls it amd64, but those are just different names for the same thing. Windows, unlike Raspberry Pi OS, is not typically managed with a package manager. Therefore, we’ll fetch all required libraries so we can package them along our binary for easy installation – this includes the cairo library and all libraries it depends on. To facilitate this, we add a postInstall script that copies all DLL files to the executable’s location. To unclutter our flake.nix, I listed the required libraries in a separate file image-server-cross/win64-deps.txt;

1pxiz0kg24r8jfh2wiqdcj4g79xrbcv2qp7jsx0c2kjq1xwfknb0 cairo-1.17.4-4-any.pkg.tar.zst
0m9h8nkymj43291jv21i9pqrwzzmmjqm0maqx6aib1gwnq5ffvbc freetype-2.11.1-2-any.pkg.tar.zst
1rxl0nv7w5zrllbbp2kps4z0q30b2pzwx31ky5il6vwa5n1k2z2b libpng-1.6.37-6-any.pkg.tar.zst
1m6ggqz2carz8vw4zc69mbxj8h8p12742xmprkwrsf7l2p44ngxr zlib-1.2.11-9-any.pkg.tar.zst
04vkjdsfdnayqbxr06p2qqfxhnk1x8sz3479avvp7bv2g7gvc42z fontconfig-2.13.94-1-any.pkg.tar.zst
1fbrmpcz1vcyfypda1pzj2zxyaks2l6hfg420drn1wn3k0flnxsb pixman-0.40.0-2-any.pkg.tar.zst
0nhbd02kyf5yp7rdyzcba64hbxgzhixs9qsn7z0pmqsf3d5a7bcw expat-2.4.1-1-any.pkg.tar.zst
0kwc5f60irrd5ayjr0f103f7qzll9wghcs9kw1v17rj5pax70bxf libiconv-1.16-2-any.pkg.tar.zst
0b2r9iszb7pn13j0fgi57wa6pib27qpijrn07a2p12zypw614rrr bzip2-1.0.8-2-any.pkg.tar.zst
1svagi9f1xy7pr4zxv083xbr6dnzpx881wa3ykwh47kjd22zwymb harfbuzz-3.1.2-1-any.pkg.tar.zst
1mkys0xhn461ip77dr2i6vyi04jx3y7ns3gjgmxj7qmbkpkny1kx brotli-1.0.9-4-any.pkg.tar.zst
0jlifmil83khss9ib25z3lihzb5qg25k6zpqpgjcmbpfakcw0apv glib2-2.70.2-2-any.pkg.tar.zst
0r4kwiv86gjzjsm8fmqcwsph1rnxcilwfs5bn4mwd13cpy43a6qb gettext-0.21-2-any.pkg.tar.zst
1z6ghnfx6psxfah1ix3mcbl7shrrpn0h299kmzdalngqmvx4bkw6 libffi-3.3-4-any.pkg.tar.zst
0xc7f0vvv9xi6nb468gn58gcma967iahlg3apwwpmzs009ijc7vq pcre-8.45-1-any.pkg.tar.zst
08vgb8paf6bj6505i89ki5ghys1r7g5v284bjqhk2sg3jjp99q7f libwinpthread-git-
0zz81rhlr53g0h0vh9xnymsji40ihb6w0vz37hw7025naarz6j3s graphite2-1.3.14-2-any.pkg.tar.zst
07hi2s9134fw4rs9xanqfdxgr6cjzpzb7hcdzviszk767kd0wvlv libc++-13.0.0-3-any.pkg.tar.zst
1wv9q8bma5vq42q8qk02dwxxl6c587lxv025wscl0dwps659lzlx libunwind-13.0.0-3-any.pkg.tar.zst

I wrote this file by navigating the web interface like a barbarian to collect the dependencies’ closure. There is probably a nicer way but I don’t know pacman well enough to figure it out. In any case, if you ever need to do this, use

nix-hash --type sha256 --to-base32 <hash>

to convert the hashes given by the package repository to what you want to have in your flake.nix. This concludes our platforms setup.

Building the Application

We’re back in our flake.nix! Compared to our previous setup, our new buildApp gains two parameters, targetPkgs and buildGoModuleOverrides:

    buildApp = {
      system, vendorSha256, plugins ? [],
      targetPkgs ? (go118pkgs system),
      buildGoModuleOverrides ? _: {}
    }: let
        pkgs = go118pkgs system;
        requireFlake = modName: ''
          require ${modName} v0.0.0
          replace ${modName} => ./vendor-nix/${modName}
        vendorFlake = modName: src: ''
          mkdir -p $(dirname vendor-nix/${modName})
          cp -r ${src} vendor-nix/${modName}

targetPkgs is the list of packages for the target system, which can potentially contain foreign packages. But, if not specified explicitly, it will just be the same as our host system’s packages. buildGoModuleOverrides is the additional configuration for cross-compiling, which is supplied by our platform definitions.

What follows is the setup of sources, which has not changed at all:

        sources = pkgs.stdenvNoCC.mkDerivation {
          name = "image-server-with-plugins-source";
          src = nix-filter.lib.filter {
            root = ./.;
            exclude = [ ./flake.nix ./flake.lock ];
          nativeBuildInputs = plugins;
          phases = [ "unpackPhase" "buildPhase" "installPhase" ];
          PLUGINS_GO = import ./plugins.go.nix nixpkgs.lib plugins;
          GO_MOD_APPEND = builtins.concatStringsSep "\n"
            ((builtins.map (p: requireFlake p.goPlugin.goModName)
             plugins) ++ [(requireFlake "example.com/api")]);
          buildPhase = ''
            mkdir vendor-nix
            ${builtins.concatStringsSep "\n"
              ((builtins.map (p: vendorFlake p.goPlugin.goModName
                              "${p}/src") plugins)
              ++ [(vendorFlake "example.com/api" api.src)])}
            printenv PLUGINS_GO >plugins.go
            echo "" >>go.mod # newline
            printenv GO_MOD_APPEND >>go.mod
          installPhase = ''
            mkdir -p $out/src
            cp -r -t $out/src *

And finally, our call to buildGo118Module (buildGoModule using Go 1.18, which has been added by our overlay):

        params = rec {
          name = "image-server";
          src = builtins.trace "sources at ${sources}" sources;
          modRoot = "src";
          subPackages = [ "." ];
          inherit vendorSha256;
          nativeBuildInputs = [ pkgs.pkg-config ];
          buildInputs = [ targetPkgs.cairo ];
          preBuild = ''
            export PATH=$PATH:${pkgs.lib.makeBinPath nativeBuildInputs}
      in pkgs.buildGo118Module
        (params // (buildGoModuleOverrides params));

The main change besides Go 1.18 is that we refer now to targetPkgs.cairo, which can potentially be a foreign library.

    crossBuildRPi4App = params: (buildApp
      (params // (platforms params.system).raspberryPi4));
    crossBuildWin64App = params: (buildApp
      (params // (platforms params.system).win64));

Not only do we want to be able to cross-compile in the main application’s Flake, we obviously also want plugin Flakes to be able to do it. Therefore, we define these two functions that cross-build our application for the respective targets, which take the same parameters as buildApp.

As discussed before, the Windows package bundles all required DLLs, but the Raspberry Pi package doesn’t and instead expects the required libraries to be install by the system’s package manager. This reeks like something we can automate, so let’s do it:

    crossBuildRPi4Deb = params: let
      pkgs = go118pkgs params.system;
      app = crossBuildRPi4App params;
      debName = "image-server-cross";
      debVersion = "1.0-1";
    in pkgs.stdenvNoCC.mkDerivation {
      name = "${debName}_${debVersion}.deb";
      phases = [ "unpackPhase" "buildPhase" "installPhase" ];
      CONTROL = ''
        Package: ${debName}
        Version: ${debVersion}
        Section: base
        Priority: optional
        Architecture: armhf
        Depends: libcairo2 (>= 1.16.0)
        Maintainer: Karl Koch <contact@example.com>
        Description: Nix+Go Demo Debian Package
      unpackPhase = ''
        mkdir -p ${debName}_${debVersion}/{usr/local/bin,DEBIAN}
        printenv CONTROL > ${debName}_${debVersion}/DEBIAN/control
        cp ${app}/bin/linux_arm/image-server ${debName}_${debVersion}/usr/local/bin/
      buildPhase = ''
        ${pkgs.dpkg}/bin/dpkg-deb --build ${debName}_${debVersion}
      installPhase = ''
        cp *.deb $out

This will emit a nice Debian package we can install on the target system via

sudo apt install ./image-server-cross_1.0-1.deb

This will take care of installing all dependencies. (Note: This is a proof-of-concept and ignores best practices for creating Debian packages.)

The Flake’s Packages

Let’s have our Flake provide the native main application, along with packages for Windows and the Raspberry Pi:

  in (utils.lib.eachDefaultSystem (system: rec {
    packages = rec {
      app = buildApp {
        inherit system;
        vendorSha256 =
      rpi4deb = crossBuildRPi4Deb {
        inherit system;
        vendorSha256 =
      win64app = crossBuildWin64App {
        inherit system;
        vendorSha256 =
    defaultPackage = packages.app;
  })) // {
    lib = {
      inherit buildApp crossBuildRPi4Deb crossBuildWin64App;
      pluginMetadata = goModFile: {
        goModName = with builtins; head
          (match "module ([^[:space:]]+).*" (readFile goModFile));

As discussed, we now also provide our two cross-compiling functions in the public lib.

Build it!

Phew, that was a long journey. First, let’s finalize everything and check that the native app still works:

git add .
nix flake update
git add flake.lock
git commit -a -m "cross compiling app"
nix run

This works. If you want, fetch some images, then kill it.

Now for the interesting part: Let’s compile for Windows!

nix build .#win64app

This should give us result/bin/windows_amd64, where we’ll find image-server.exe along with all required DLLs. If you have a Windows system, you can test the executable there. People on Linux can run it via wine, or so I’m told (untested):

nix run nixpkgs#wine.wineWowPackages.stable -- result/bin/windows_amd64/image-server.exe

However, this seems to not be supported on macOS.

Let’s test the Raspberry Pi build:

nix build .#rpi4deb
readlink result

This gives us something like:


This can be copied to your Raspberry Pi and installed via apt install. Success!

OCI Image

The last thing we’ll do is to create an OCI container image. For this, we’ll simply add another package to our image-server (behind the win64app):

      container-image =
        nixpkgs.legacyPackages.${system}.dockerTools.buildImage {
          name = "image-server-oci";
          tag = "latest";
          contents = app;
          config = {
            Cmd = "/bin/image-server";
            ExposedPorts = { "8080" = {}; };

Commit and run:

git commit -a -m "container image"
nix build .#container-image
readlink result

This should give you something like


This is a gzipped tarball which can be loaded for example into Docker via

gunzip -c result | docker load

I won’t go into details about how to run Docker images since that is documented in detail elsewhere. You can also directly consume OCI containers in NixOS!

Usable Docker images must contain Linux binaries, therefore this won’t work on macOS. The easiest solution there would probably be to use a NixOS build container or VM. You could possibly also set up Nix’ actual cross-compiling system, but this is not something I will explore here.

Our container-image derivation contains only the main application without any plugins. As an exercise, write a function that can build an image from a list of plugins!


With this article, I set out to show that Nix Flakes can be a viable alternative to Go’s -buildmode=plugin. In my opinion, it was largely a success in that I managed to be able to target even Windows. The main drawbacks are that I used beta software (Go 1.18 and Zig) to achieve that goal. These seem to be minor though, as Go 1.18 proper is set to be released in March 2022, and while Zig as a language has not reached a major version yet, we only use its bundled clang compiler which is production-ready. The error we ran into when trying to build for the Raspberry Pi is just missing header files for glibc, which hopefully will be fixed in a future release.

Final Words

The topics we explored are quite complex. It is likely that there are flaws in this article. If you have suggestions on how to improve it, you can use the GitHub repository’s issue tracker.