Haskell Project Template with Nix Flakes
This post introduces my Haskell project template powered by Nix Flakes – a simple setup I use to quickly spin up new Haskell applications.
Haskell has been my go-to language for over 7 years. First, I started with Stack, then switched to plain Cabal and finally settled on Nix to provision a development environment for Haskell projects.
Since I primarily develop applications rather than libraries, I find it more practical to structure my development environment and builds around producing standalone Nix packages, statically built executables and container images using Nix.
Nix offers several approaches for working with Haskell. In my experience, the three most prominent are:
- The Haskell infrastructure in Nixpkgs – simple and built-in.
- Haskell.nix – a more flexible but complex option from IOHK.
- haskell-flake – a recent and modular setup based on flake-parts.
I tried the second option a few years ago but have not tried the third one. I have been using the first one, using classic Nix. Only recently, I have refactored some of my projects to use Nix Flakes. Today, I also refactored my Haskell project template to use Flakes.
My plan is to invest some time in flake-parts and Horizon Haskell. In this post, I will simply go through my template’s flake.nix
file and show what it does.
The template may evolve, but the examples in this post reference a pinned version for consistency. Also note that the template is a GitHub Repository Template that you can use to create a new repository with the same structure. Check out the run-template.sh
script that will help you update template variables.
The flake.nix
Here is the flake.nix
file:
{
description = "Haskell Project Template";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
## Import nixpkgs:
pkgs = import nixpkgs { inherit system; };
## Load readYAML helper:
readYAML = pkgs.callPackage ./nix/read-yaml.nix { };
## Read package information:
package = readYAML ./package.yaml;
## Get our Haskell:
thisHaskell = pkgs.haskellPackages.override {
overrides = self: super: {
${package.name} = self.callCabal2nix package.name ./. { };
};
};
## Prepare dev-test-build script:
dev-test-build = pkgs.writeShellApplication {
name = "cabal-dev-test-build";
text = builtins.readFile ./nix/dev-test-build.sh;
runtimeInputs = [ pkgs.bash pkgs.bc pkgs.moreutils ];
};
## Prepare Nix shell:
thisShell = thisHaskell.shellFor {
## Define packages for the shell:
packages = p: [ p.${package.name} ];
## Enable Hoogle:
withHoogle = false;
## Build inputs for development shell:
buildInputs = [
## Haskell related build inputs:
thisHaskell.apply-refact
thisHaskell.cabal-fmt
thisHaskell.cabal-install
thisHaskell.cabal2nix
thisHaskell.fourmolu
thisHaskell.haskell-language-server
thisHaskell.hlint
thisHaskell.hpack
thisHaskell.weeder
## Our development scripts:
dev-test-build
## Other build inputs for various development requirements:
pkgs.docker-client
pkgs.git
pkgs.nil
pkgs.nixpkgs-fmt
pkgs.nodePackages.prettier
pkgs.upx
];
};
thisPackage = pkgs.haskell.lib.justStaticExecutables (
thisHaskell.${package.name}.overrideAttrs (oldAttrs: {
nativeBuildInputs = (oldAttrs.nativeBuildInputs or [ ]) ++ [
pkgs.git
pkgs.makeWrapper
pkgs.ronn
];
postFixup = (oldAttrs.postFixup or "") + ''
## Create output directories:
mkdir -p $out/{bin}
## Wrap program to add PATHs to dependencies:
wrapProgram $out/bin/${package.name} --prefix PATH : ${pkgs.lib.makeBinPath []}
'';
})
);
thisDocker = pkgs.dockerTools.buildImage {
name = "${package.name}";
tag = "v${package.version}";
created = "now";
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [ pkgs.cacert ];
pathsToLink = [ "/etc" ];
};
runAsRoot = ''
#!${pkgs.runtimeShell}
${pkgs.dockerTools.shadowSetup}
groupadd -r users
useradd -r -g users patron
'';
config = {
User = "patron";
Entrypoint = [ "${thisPackage}/bin/${package.name}" ];
Cmd = null;
};
};
in
{
## Project packages output:
packages = {
"${package.name}" = thisPackage;
docker = thisDocker;
default = self.packages.${system}.${package.name};
};
## Project development shell output:
devShells = {
default = thisShell;
};
});
}
The 125-line flake.nix
file might look a bit overwhelming at first, but it is actually quite simple considering that this file gives us the following:
- A Nix-installable package,
- A Docker image with the package application as the entrypoint, and
- A development shell with all the development dependencies.
There are some placeholders in the file, such as building man
pages with ronn
during the package’s postFixup
phase. I am not using this feature in all of my projects, but it comes in handy when I need it. Likewise, we can add extra bin
paths to the PATH
variable in the postFixup
phase for system binaries.
Let us go over notable parts and features of the file.
First of all, we do not refer to the name of the package. Instead, we load the package.yaml
file and read the name and version from it. Note that the readYAML
function is a simple helper I manage in my nix
directory as there is no built-in for reading YAML files in Nix yet:
readYAML = pkgs.callPackage ./nix/read-yaml.nix { };
package = readYAML ./package.yaml;
Then, we create a Haskell package set with our package included:
thisHaskell = pkgs.haskellPackages.override {
overrides = self: super: {
${package.name} = self.callCabal2nix package.name ./. { };
};
};
This is used when we create the development shell:
thisShell = thisHaskell.shellFor {
packages = p: [ p.${package.name} ];
buildInputs = [
## ...
];
};
… and when we create the package:
thisPackage = pkgs.haskell.lib.justStaticExecutables (
thisHaskell.${package.name}.overrideAttrs (oldAttrs: {
nativeBuildInputs = (oldAttrs.nativeBuildInputs or [ ]) ++ [
## ...
];
postFixup = (oldAttrs.postFixup or "") + ''
## ...
'';
})
);
Finally, we build a Docker image with the package as the entrypoint:
thisDocker = pkgs.dockerTools.buildImage {
## ...
config = {
User = "patron";
Entrypoint = [ "${thisPackage}/bin/${package.name}" ];
Cmd = null;
};
};
The tools I include in this flake.nix
file come in handy, especially with other features of this template. For example, I included a cabal dev-test-build
script that helps me build the package, run tests, linters and formatters. Additionally, and, probably most importantly, I have a script to statically build the application in a Docker image and compress it with upx
to reduce the size of the final binary.
I am pretty sure that there are many better ways to do what I do in this template repository, but I am happy using it to quickly spin up a new Haskell project. There are quite a few other things and methods I use in real-life projects, such as multi-package builds and GitHub Release Please Action integration, which are quite easy to add upon this template.
If you find this template useful, consider starring the repository or bookmarking it for future reference. You might also want to check out my new Nix Flake Templates collection, where I plan to share more Haskell setups as my use cases expand.