Deploying and packaging Haskell applications can be challenging at times, and runtime library dependencies are one reason for this. Statically linked binaries have no such dependencies and are therefore easier to deploy. They can also be quicker to start, since no dynamic loading is needed. In exchange, all used symbols must be bundled into the application, which may lead to larger artifacts.

Thanks to the contribution of Will Jones of Habito1, rules_haskell, the Haskell Bazel extension, has gained support for fully static linking of Haskell binaries.

Habito uses Bazel to develop, build, test and deploy Haskell code in a minimal Docker container. By building fully-statically-linked binaries, Docker packaging (using rules_docker) becomes straightforward and easy to integrate into existing build workflows. A static binary can also be stripped once it is built to reduce the size of production artifacts. With static binaries, what you see (just the binary) is what you get, and this is powerful.

In the following, we will discuss the technical challenges of statically linking Haskell binaries and how these challenges are addressed in rules_haskell. Spoiler alert: Nix is an important part of the solution. Finally, we will show you how you can create your own fully statically linked Haskell binaries with Bazel and Nix.

Technical challenges

Creating fully statically linked Haskell binaries is not without challenges. The main difficulties for doing so are:

  • Not all library dependencies are suited for statically linked binaries.
  • Compiling template Haskell requires dynamic libraries on Linux by default.

Library dependencies

Like most binaries on Linux, the Haskell compiler GHC is typically configured to link against the GNU C library glibc. However, glibc is not designed to support fully static linking and explicitly depends on dynamic linking in some use cases. The alternative C library musl is designed to support fully static linking.

Relatedly, there may be licensing reasons to not link some libraries statically. Common instances in the Haskell ecosystem are again glibc which is licensed under GPL, and the core Haskell dependency libgmp which is licensed under LGPL. For the latter GHC can be configured to use the core package integer-simple instead of integer-gmp.

Fortunately, the Nix community has made great progress towards fully statically linked Haskell binaries and we can build on much of this work in rules_haskell. The rules_nixpkgs extension makes it possible to import Nix derivations into a Bazel project, and rules_haskell has first class support for Nix-provided GHC toolchains using rules_nixpkgs under the hood. In particular, it can import a GHC toolchain based on musl from static-haskell-nix.

Template Haskell

By default GHC is configured to require dynamic libraries when compiling template Haskell. GHC’s runtime system (RTS) can be built in various combinations of so called ways. The relevant way in this context is called dynamic. On Linux, GHC itself is built with a dynamic RTS. However, statically linked code is targeting a non-dynamic RTS. This may sound familiar if you ever tried to compile code using template Haskell in profiling mode. As the GHC user guide points out, when evaluating template Haskell splices, GHC will execute compiled expressions in its built-in bytecode interpreter and this code has to be compatible with the RTS of GHC itself. In short, a GHC configured with a dynamic RTS will not be able to load static Haskell libraries to evaluate template Haskell splices.

One way to solve this issue is to compile all Haskell libraries twice, once with dynamic linking and once with static linking. C library dependencies will similarly need to be available in both static and dynamic forms. This is the approach taken by static-haskell-nix. However, in the context of Bazel we found it preferable to only compile Haskell libraries once in static form and also only have to provide C libraries in static form. To achieve this we need to build GHC with a static RTS and to make sure that Haskell code is compiled as position independent code so that it can be loaded into a running GHC for template Haskell splices. Thanks to Nix, it is easy to override the GHC derivation to include the necessary configuration.

Make your project fully statically linked

How can you benefit from this? In this section we will show how you can setup a Bazel Haskell project for fully static linking with Nix. For further details please refer to the corresponding documentation on haskell.build. A fully working example repository is available here. For a primer on setting up a Bazel Haskell project take a look at this tutorial.

First, you need to configure a Nixpkgs repository that defines a GHC toolchain for fully static linking based on musl. We start by pulling in a base Nixpkgs revision and the static-haskell-nix project. Create a default.nix, with the following.

let
  baseNixpkgs = builtins.fetchTarball {
    name = "nixos-nixpkgs";
    url = "https://github.com/NixOS/nixpkgs/archive/dca182df882db483cea5bb0115fea82304157ba1.tar.gz";
    sha256 = "0193bpsg1ssr93ihndyv7shz6ivsm8cvaxxl72mc7vfb8d1bwx55";
  };

  staticHaskellNixpkgs = builtins.fetchTarball
    "https://github.com/nh2/static-haskell-nix/archive/dbce18f4808d27f6a51ce31585078b49c86bd2b5.tar.gz";
in

Then we import a Haskell package set based on musl from static-haskell-nix. The package set provides GHC and various Haskell packages. However, we will only use the GHC compiler and use Bazel to build other Haskell packages.

let
  staticHaskellPkgs = (
    import (staticHaskellNixpkgs + "/survey/default.nix") {}
  ).approachPkgs;
in

Next we define a Nixpkgs overlay that introduces a GHC based on musl that is configured to use a static runtime system and core packages built with position independent code so that they can be loaded for template Haskell.

let
  overlay = self: super: {
    staticHaskell = staticHaskellPkgs.extend (selfSH: superSH: {
      ghc = (superSH.ghc.override {
        enableRelocatedStaticLibs = true;
        enableShared = false;
      }).overrideAttrs (oldAttrs: {
        preConfigure = ''
          ${oldAttrs.preConfigure or ""}
          echo "GhcLibHcOpts += -fPIC -fexternal-dynamic-refs" >> mk/build.mk
          echo "GhcRtsHcOpts += -fPIC -fexternal-dynamic-refs" >> mk/build.mk
        '';
      });
    });
  };
in

Finally, we extend the base Nixpkgs revision with the overlay. This makes the newly configured GHC available under the Nix attribute path staticHaskell.ghc.

  args@{ overlays ? [], ... }:
    import baseNixpkgs (args // {
      overlays = [overlay] ++ overlays;
    })

This concludes the Nix part of the setup and we can move on to the Bazel part.

You can import this Nixpkgs repository into Bazel by adding the following lines to your WORKSPACE file.

load(
    "@io_tweag_rules_nixpkgs//nixpkgs:nixpkgs.bzl",
    "nixpkgs_local_repository",
)
nixpkgs_local_repository(
    name = "nixpkgs",
    nix_file = "default.nix",
)

Now you can define a GHC toolchain for rules_haskell that uses the Nix built GHC defined above. Note how we declare that this toolchain has a static RTS and is configured for fully static linking. Add the following lines to your WORKSPACE file.

load(
    "@rules_haskell//haskell:nixpkgs.bzl",
    "haskell_register_ghc_nixpkgs",
)
haskell_register_ghc_nixpkgs(
    version = "X.Y.Z",  # Make sure this matches the GHC version.
    attribute_path = "staticHaskell.ghc",
    repositories = {"nixpkgs": "@nixpkgs"},
    static_runtime = True,
    fully_static_link = True,
)

GHC relies on the C compiler and linker during compilation. rules_haskell will always use the C compiler and linker provided by the active Bazel C toolchain. We need to make sure that we use a musl-based C toolchain as well. Here we will use the same Nix-provided C toolchain that is used by static-haskell-nix to build GHC.

load(
    "@io_tweag_rules_nixpkgs//nixpkgs:nixpkgs.bzl",
    "nixpkgs_cc_configure",
)
nixpkgs_cc_configure(
    repository = "@nixpkgs",
    nix_file_content = """
      with import <nixpkgs> { config = {}; overlays = []; }; buildEnv {
        name = "bazel-cc-toolchain";
        paths = [ staticHaskell.stdenv.cc staticHaskell.binutils ];
      }
    """,
)

Finally, everything is configured for fully static linking. You can define a Bazel target for a fully statically linked Haskell binary as follows.

haskell_binary(
    name = "example",
    srcs = ["Main.hs"],
    features = ["fully_static_link"],
)

You can build your binary and confirm that it is fully statically linked as follows.

$ bazel build //:example
$ ldd bazel-bin/example
      not a dynamic executable

Conclusion

If you’re interested in further exploring the benefits of fully statically linked binaries, you might combine them with rules_docker (e.g. through its container_image rule) to build Docker images as Habito have done. With a rich enough set of Bazel rules and dependency specifications, it’s possible to reduce your build and deployment workflow to a bazel test and bazel run!

The current implementation depends on a Nix-provided GHC toolchain capable of fully static linking that is imported into Bazel using rules_nixpkgs. However, there is no reason why it shouldn’t be possible to use a GHC distribution capable of fully static linking that was provided by other means, for example a Docker image such as ghc-musl. Get in touch if you would like to create fully statically linked Haskell binaries with Bazel but can’t or don’t want to integrate Nix into your build. Contributions are welcome!

We thank Habito for their contributions to rules_haskell.


  1. Habito is fixing mortgages and making homebuying fit for the future. Habito gives people tools, jargon-free knowledge and expert support to help them buy and finance their homes. Built on a rich foundation of functional programming and other cutting-edge technology, Habito is a long time user of and contributor to rules_haskell.