IHP is often spoken of as “Haskell on Rails”, and for good reason. The framework has strong opinions on how you should structure and build applications and enforces these through the built-in code generators and Nix project scaffolding. This makes getting started a wonderful experience, especially for those less experienced in the Haskell world. This also comes with trade-offs. As the applications I built became more complex, for example, I began to feel the pain of the limited ability to customize the default project structure. Specifically, I wanted to build my project with profiling enabled to help measure performance and find bottlenecks.

This led me on a journey of exploring how the IHP build system works, running into blockers, and ending up with an IHP project configured fully with Cabal and Nix, very similar to a traditional Haskell project.

Click here for the final project

Project Setup

We’ll first go through how the default IHP build system works. If you’d rather just setup a Cabal based project, feel free to skip to the following section.

Default IHP Build System

IHP comes with a build system consisting mainly of a few Nix files spread across the IHP framework and the project generated from the default scaffolding and a Makefile which calls GHC directly to compile the project. This is rather unusual - most Haskell projects use Cabal or Stack to build, not direct calls to GHC. As you utilize more and more GHC features (such as profiling) this gets messy.

The root of an IHP project is its default.nix.

let
    ihp = builtins.fetchGit {
        url = "https://github.com/digitallyinduced/ihp.git";
        ref = "refs/tags/v0.17.0";
    };
    haskellEnv = import "${ihp}/NixSupport/default.nix" {
        ihp = ihp;
        haskellDeps = p: with p; [
            cabal-install
            base
            wai
            text
            hlint
            p.ihp
        ];
        otherDeps = p: with p; [
            # Native dependencies, e.g. imagemagick
        ];
        projectPath = ./.;
    };
in
    haskellEnv

It builds the “haskellEnv” from ${ihp}/NixSupport/default.nix which is a long Nix derivation that calls make commands to build the various parts of the project and installs them. Importantly, it uses nixpkgs bulit from pkgs = import "${toString projectPath}/Config/nix/nixpkgs-config.nix" { ihp = ihp; additionalNixpkgsOptions = additionalNixpkgsOptions; }; which calls back to our project.

# See https://ihp.digitallyinduced.com/Guide/package-management.html
{ ihp, additionalNixpkgsOptions, ... }:
import "${toString ihp}/NixSupport/make-nixpkgs-from-options.nix" {
    ihp = ihp;
    haskellPackagesDir = ./haskell-packages/.;
    additionalNixpkgsOptions = additionalNixpkgsOptions;
}

This just passes a few arguments back to IHP to the ${toString ihp}/NixSupport/make-nixpkgs-from-options.nix file. Here, a Nixpkgs is built which contains overrides for all the Haskell projects the application uses. This file uses a very similar technique to Gabriella Gonzalez’s guide which allows lots of flexibility. This file is the key to making this all work, and it turns out we don’t even need to touch it! Good design paying off :)

Where is Cabal?

Cabal is used to build the IHP library and executables by a call to callPackage with the ihp.nix file. This file looks to be generated from cabal2nix, a tool we’ll be using later to generate a Nix file from a Cabal file. callPackage then uses the default Nix Haskell builder which uses cabal and builds a project normally.

However, Cabal is never called directly for our application. There is an App.cabal file generated with the project scaffolding, but as noted in the file, this is only there for tooling purposes and is not necessary. Instead, IHP/NixSupport/default.nix calls the IHP Makefile which builds your project with GHC directly.

This comes with several benefits, such as being able to automatically build executables for every script in the project without needing to specify them in a Cabal file. With this, however, comes a significant loss of flexibility since we are unable to use all the fancy Cabal features that abstract away configuring GHC.

Cabal based: A working setup

For the Cabal based setup we will first need an updated App.cabal which accurately describes the project, as well as an updated nix setup. Let’s start with an example Cabal file:

cabal-version:       2.2
name:                App
version:             0.1.0.0
build-type:          Simple

Flag Prod
    Description: Build for production
    Manual: True
    Default: False

common shared-extensions
  default-extensions:
    OverloadedStrings
    , NoImplicitPrelude
    ...


common shared-deps
  build-depends:
      base
    , classy-prelude
    , mono-traversable
    , ihp
    ...

common shared-opts
  default-language:    Haskell2010
  if flag(Prod)
    ghc-options:
        -O2
        -threaded
  else
    ghc-options:
        -O0
        -threaded


executable App
  import: shared-extensions
  import: shared-deps
  import: shared-opts

  main-is:             Main.hs
  hs-source-dirs:      . build Config

executable TestScript
  import: shared-extensions
  import: shared-deps
  import: shared-opts

  main-is:             Application/Script/TestScript.hs
  hs-source-dirs:      . build Config

executable RunJobs
  import: shared-extensions
  import: shared-deps
  import: shared-opts

  main-is:             Application/Script/RunJobs.hs
  hs-source-dirs:      . build Config

Notice we define three executables. Both the script and the job runner would have been built automatically by the derivation defined by IHP, but since we’re on our own now, we must write them explicitly. A section will need to be added for every script as well. The RunJobs script also is not included in the project from the scaffolding: IHP writes this file out from a make command and then builds it. We include it explicitly.

default.nix

let
    ihp = builtins.fetchGit {
        url = "https://github.com/digitallyinduced/ihp.git";
        ref = "refs/tags/v0.17.0";
    };
    haskellLib = pkgs.haskell.lib;

    additionalNixpkgsOptions = { allowUnfree = true; };
    pkgs = import "${toString ihp}/NixSupport/make-nixpkgs-from-options.nix" {
        inherit ihp additionalNixpkgsOptions;
        haskellPackagesDir = ./Config/nix/haskell-packages/.;
    };
    haskellLib = pkgs.haskell.lib;
    haskellPackages = pkgs.haskell.packages.ghc8107;
    package = isProd:
      (haskellPackages.callCabal2nixWithOptions
        "App" # cabal file name
        ./.   # source directory
        (if isProd then "--flag Prod" else "")  # cabal flags
        {} # additional options
      ).overrideAttrs (oldAttrs: {
        preBuild = (if (builtins.hasAttr "preBuild" oldAttrs) then oldAttrs.preBuild else "") + "${haskellPackages.ihp}/bin/build-generated-code";
        installPhase = oldAttrs.installPhase + ''
          mkdir -p $out/IHP $out/static

          cp -r $src/static $out
          cp -r ${haskellPackages.ihp}/lib/IHP/static $out/IHP
          '';
      });
in
  if pkgs.lib.inNixShell
    then
      haskellPackages.shellFor {
        packages = p: [
          (package false)
        ];
        buildInputs = with haskellPackages; [
          pkgs.cabal-install
          pkgs.postgresql

          ihp # for IHP IDE executables
        ];
        withHoogle = true;
      }
    else
      (package true)

The meat of the new setup is in the package attribute:

package = isProd:
    (haskellPackages.callCabal2nixWithOptions
    "App" # cabal file name
    ./.   # source directory
    (if isProd then "--flag Prod" else "")  # cabal flags
    {} # additional options
    ).overrideAttrs (oldAttrs: {
    preBuild = (if (builtins.hasAttr "preBuild" oldAttrs) then oldAttrs.preBuild else "") + "${haskellPackages.ihp}/bin/build-generated-code";
    installPhase = oldAttrs.installPhase + ''
        mkdir -p $out/IHP $out/static

        cp -r $src/static $out
        cp -r ${haskellPackages.ihp}/lib/IHP/static $out/IHP
        '';
    });

This uses cabal2nix to convert the Cabal file we build above to a nix derivation which is then built to produce the project executables or environment. We need to override some of the build steps to get things where IHP expects them. Specifically, we need to ensure we have a build/Generated/Types.hs file containing our application types generated from the database schema, which we do in the preBuild step. After installation, we copy the application static files and the IHP static files to their proper place.

What is built exactly depends on the final value of the expression:

  if pkgs.lib.inNixShell
    then
      haskellPackages.shellFor {
        packages = p: [
          (package false)
        ];
        buildInputs = with haskellPackages; [
          pkgs.cabal-install
          pkgs.postgresql

          ihp # for IHP IDE executables (codegens, migrations, etc)
        ];
        withHoogle = true;
      }
    else
      (package true)

In a nix shell, we use the convenient shellFor function to build an environment using our package along with the packages needed for IHP development. If we’re not in a shell, then just return the project built in release mode.

Cabal based development workflow

The development workflow is similar between both structures. In the cabal approach, I do not use .envrc due to personal preference, but it should work if you continue use the normal IHP make command to build that file.

To run the dev server,

nix-shell --run RunDevServer

In another terminal, I’d highly recommend starting a local hoogle server:

nix-shell --run "hoogle server --local"

which will allow you to easily search through all the packages you have available (including IHP!!!)

You can also run any Cabal command as normal: in a nix shell for example:

cabal repl App

will start a GHCI session with the main application loaded.

Finally, to simply build the project, use nix-build. The result can be found, ready for deployment, in the result symlink.

Adding Profiling

At this point we’ve just recreated the default IHP workflow with Cabal. Now let’s see what power that gives us by adding profiling to the project.

To profile with GHC, all packages must be installed with profiling enabled. We can do this in our default.nix by adding an override to nixpkgs itself.

manualOverrides = haskellPackagesNew: haskellPackagesOld:
    {
    # This function is called for building each haskell package.
    # By overriding it here, we can pass in custom settings globally.
    mkDerivation = args: haskellPackagesOld.mkDerivation (args // {
        enableLibraryProfiling = true;
        enableExecutableProfiling = true;
        doCheck = false;
        doHaddock = false;
        doHoogle = false;
    });

    # We don't want to enable profiling for build tools.
    cabal2nix = haskellLib.disableLibraryProfiling (haskellLib.disableExecutableProfiling haskellPackagesOld.cabal2nix);
    hackage2nix = haskellLib.disableLibraryProfiling (haskellLib.disableExecutableProfiling haskellPackagesOld.hackage2nix);

    # Marked broken, but works fine.
    contiguous = haskellLib.unmarkBroken haskellPackagesOld.contiguous;
    };

additionalNixpkgsOptions = { allowUnfree = true; };

pkgs = import "${toString ihp}/NixSupport/make-nixpkgs-from-options.nix" {
    inherit ihp manualOverrides additionalNixpkgsOptions;
    haskellPackagesDir = ./Config/nix/haskell-packages/.;
};

Our manualOverrides are passed to the IHP function which constructs nixpkgs. When evaluating haskell packages, it will use our updated mkDerivation function which requests that profiling be enabled. Easy!

Configuring Cabal

We could have done the above with vanilla IHP, but we would have been unable to update the build scripts to pass all the flags required to GHC to build an executable with profiling. With Cabal we can do this easily. First, enter a nix-shell (will have to build a lot of dependencies, this will take a while) and run:

cabal configure --enable-profiling --ghc-options='-fprof-auto -with-rtsopts="-N -p -s -h -i0.1"'

This will create a cabal.project.local file so we don’t need to specify these every time. Next, run the project with

cabal run exe:App -O2

Open your project in your browser, do some actions, and quit the app. There will then be an App.prof file generated with profiling information generated from GHC.

Conclusion

When deciding if a big, opinionated framework like IHP is right for your project, one of the most important things to consider is the escape hatch. It is inevitable that at some point, something will come up that the default structure IHP puts you in cannot handle. Though there appears at first to be tons of “magic”, as you can hopefully see now, all IHP is is a Haskell library with some opinionated build and development tooling around it. We were able to easily reuse IHP Nix functions to define our own way of building the project and ended up with a seemingly traditional Haskell application that just depends on IHP like it does any other library.

This is the setup I will be using for all my IHP projects, and I look forward to having a discussion with the broader IHP community to hear if this is something worth integrating into the framework itself.

Please don’t hesitate to comment below or reach out with any questions!

Once again, Click here for the final project and complete configuration files.