I absolutely adore Haskell. I wouldn’t consider myself an excellent Haskell programmer, but it brings me joy to conceive and re-conceive of solutions within Haskell’s paradigm.
What doesn’t bring me joy about Haskell is the build system - i.e., the deprecated Haskell
Platform, Cabal, and all the wrappers around Cabal. Packages that depend on each other, but
also on base, with some latest packages updated and others not. Argh!
This is a common enough occurrence. A nice person says:
“Oh! You’re writing a shell script. Why don’t you use ShellCheck to help you?”
With the response:
“Uhh yeah but I’m building X from source, and pulling in ShellCheck means I have to compile Haskell…”
Since switching over to Nix, I’ve managed to come up with a decent Nix shell that I’m comfortable with, eliminating all those frustrating build complications that I ran into during my early Haskell days. However, something I had never quite figured out to my satisfaction was building a statically-linked binary for using on another system.
That changes today with Nix!
Haskell’s “Fake” -static Flag
One of my earlier unpleasant surprises went like so. Observe these lines in cabal build --help:
$ cabal build --help
Compile targets within the project.
Usage: cabal build [TARGETS] [FLAGS]
...
Flags for build:
...
--enable-static Enable Static library
--disable-static Disable Static library
--enable-executable-dynamic Enable Executable dynamic linking
--disable-executable-dynamic Disable Executable dynamic linking
--enable-executable-static Enable Executable fully static linking
--disable-executable-static Disable Executable fully static linking
...
Look look, static linking flags!
Switch them on, however, and you still end up with a binary that ldd reports as relying on:
- libz
- libgmp
- libc
- libm
- librt
- libffi
How frustrating. Turns out a few things contribute to this sad and very un-static state of affairs.
- glibc makes it hard to statically link, and Haskell links to libc.
- The Haskell runtime almost always requires
libgmp,zlib,libffi, etc., and these libraries are usually built dynamically on a desktop distro. - The Static flags lead Cabal to statically link to Haskell libraries as far as possible, but not dynamic system libraries.
The Old Containers Solution
Since we have a rough idea of the root causes of the problem, it seems like the a solution would work like so:
- glibc: Build Haskell programs against a different libc, like Musl
- Dynamic system libs: Ensure that the
.astatic libs are available in the environment, and link against them with the-staticflags.
The simplest way to achieve this would be to have a VM or container with a musl-based distro, cross your fingers and hope that your desired Cabal wrapper (like Stack) runs in that environment, and build all your distributable binaries there.
I used to keep a lovely Alpine container around just for this purpose. Frustratingly, Stack (which used
to be my Cabal-wrapper of choice), does not work reliably in that environment, which led to a lot of
pinning to ancient Stackage LTS versions, manually replacing Stack-pulled dependencies with Cabal ones,
and manually running ghc -o Main Main.hs.
Others have also released Dockerfiles following this approach: see haskell-static-alpine
The Nix Flakes Solution
Since switching over to Nix, however, a whole world of opportunities has revealed itself!
Nix already has good support for “outside-the-norm” compilation processes, such as arch cross-compilation
(through pkgsCross), cross-platform compilation (linux vs darwin), etc. Turns out Nix also has a
pkgsStatic and a Musl-libc-linked pkgsMusl set! Nix commands are also akin to esoteric incantations,
so I’ll attempt here to explain how I made it work in my own words:
- Invoke the
pkgsMuslset of derivations instead of just<nixpkgs>’spkgs(this solves libc)- I.e., where
pkgsused to be<nixpkgs>, set it to<nixpkgs>.pkgsMusl
- I.e., where
- Tell Cabal not to build shared executables or shared libraries
- Pass the
-staticflag to Cabal, so it passes it toghc - Expose the static versions of the classic GHC system libraries (e.g. zlib, libgmp) to Cabal
Here’s an example that goes through each step in a recent Nix flake I made. My original dynamically linked flake.nix:
{
description = ""; # Add description
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem
(system:
let pkgs = nixpkgs.legacyPackages.${system};
haskellPackages = pkgs.haskell.packages.ghc928;
packageName = ""; # Add package name
jailbreakUnbreak = pkg: pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; }));
in
{
packages.${packageName} = haskellPackages.callCabal2nix packageName self rec {};
defaultPackage = self.packages.${system}.${packageName};
devShell = pkgs.mkShell {
buildInputs = with haskellPackages; [
haskell-language-server
cabal-install
cabal2nix
] ++ [ pkgs.zlib ];
inputsFrom = builtins.attrValues self.packages.${system};
};
}
);
}Invoke pkgsMusl instead of pkgs
Replace
pkgs = nixpkgs.legacyPackages.${system};with
pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;Tell Cabal not to build Shared Executables or Libraries
First, move the callCabal2nix function invocation into the let block:
...
flake-utils.lib.eachDefaultSystem
(system:
let pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;
haskellPackages = pkgs.haskell.packages.ghc928;
packageName = "swankybar";
jailbreakUnbreak = pkg: pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; }));
inherit (pkgs.haskell.lib) appendConfigureFlags justStaticExecutables; # <- Need this for the next step
mypackage = haskellPackages.callCabal2nix packageName self rec {}; # <- Moved here
...Next, in the in block, use pkgs.haskell.lib.overrideCabal to override attributes in callCabal2nix. Disable shared executables and libraries.
let pkgs = ...
...
in
{
packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
enableSharedExecutables = false; # <- Added
enableSharedLibraries = false; # <- Added
});
...
}Pass the -static flag to Cabal
In the overrideCabal call, add some configure flags:
let pkgs = ...
...
in
{
packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
enableSharedExecutables = false;
enableSharedLibraries = false;
configureFlags = [
"--ghc-option=-optl=-static" # <- Added
];
});
...
}Expose the Static system libraries to Cabal
In the overrideCabal call, add some more configure flags:
let pkgs = ...
...
in
{
packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
enableSharedExecutables = false;
enableSharedLibraries = false;
configureFlags = [
"--ghc-option=-optl=-static"
"--extra-lib-dirs=${pkgs.gmp6.override { withStatic = true; }}/lib" # <- Added for libgmp
"--extra-lib-dirs=${pkgs.zlib.static}/lib" # <- Added for zlib
"--extra-lib-dirs=${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib" # <- Added for libffi
"--extra-lib-dirs=${pkgs.ncurses.override { enableStatic = true; }}/lib" # <- Added for libcurses
];
});
...
}Summary
If you’re comfortable reading diffs, here’s all I changed.
diff flake.nix flake-static.nix
12c12
< let pkgs = nixpkgs.legacyPackages.${system};
---
> let pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;
15a16,18
> inherit (pkgs.haskell.lib) appendConfigureFlags justStaticExecutables;
> mypackage = haskellPackages.callCabal2nix packageName self rec {
> };
18c21,31
< packages.${packageName} = haskellPackages.callCabal2nix packageName self rec {};
---
> packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
> enableSharedExecutables = false;
> enableSharedLibraries = false;
> configureFlags = [
> "--ghc-option=-optl=-static"
> "--extra-lib-dirs=${pkgs.gmp6.override { withStatic = true; }}/lib"
> "--extra-lib-dirs=${pkgs.zlib.static}/lib"
> "--extra-lib-dirs=${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib"
> "--extra-lib-dirs=${pkgs.ncurses.override { enableStatic = true; }}/lib"
> ];
> });The final flake.nix file looks like this:
{
description = ""; # Add description
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem
(system:
let pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;
haskellPackages = pkgs.haskell.packages.ghc928;
packageName = ""; # Add package name
jailbreakUnbreak = pkg: pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; }));
inherit (pkgs.haskell.lib) appendConfigureFlags justStaticExecutables;
mypackage = haskellPackages.callCabal2nix packageName self rec {
};
in
{
packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
enableSharedExecutables = false;
enableSharedLibraries = false;
configureFlags = [
"--ghc-option=-optl=-static"
"--extra-lib-dirs=${pkgs.gmp6.override { withStatic = true; }}/lib"
"--extra-lib-dirs=${pkgs.zlib.static}/lib"
"--extra-lib-dirs=${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib"
"--extra-lib-dirs=${pkgs.ncurses.override { enableStatic = true; }}/lib"
];
});
defaultPackage = self.packages.${system}.${packageName};
devShell = pkgs.mkShell {
buildInputs = with haskellPackages; [
haskell-language-server
cabal-install
cabal2nix
] ++ [ pkgs.zlib ];
inputsFrom = builtins.attrValues self.packages.${system};
};
}
);
}Results
And after a quick nix build (no, it wasn’t quick. It bootstrapped a whole bunch of Musl executables and libraries, as one might
expect from a cross-compile), I finally saw that wondrous declaration from ldd:
$ ldd result/bin/output
not a dynamic executable
Simply beautiful.