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.