When installing software with Nix, if the target isn’t available in a binary cache somewhere, the software will be built on your machine from source. This is fine for a development machine, but when deploying software on a remote server this can be tricky depending on the resources available. Building Haskell programs is a long, resource intensive process, so on a t2.micro EC2 instance it’s pretty much a lost cause.

Luckily, with GitHub Actions we can use Docker and Nix to build our IHP app and ship a small image containing only what is necessary to run the app. This took a lot of trial and error to get working, so hopefully this can help you save time and get your application up and running!

Building a Nix derivation

I wasn’t able to use nix-build to build a derivation of my project with the Nix files included with IHP, so to get this working I copied the structure of the Nix files included in the project and changed what was needed to get a build to work. I split up the code into two files, default.nix which is used as the entry point for nix-build, and build.nix which actually creates the derivation for the project.

These files assume your IHP project lives in a folder named ./web.

# default.nix
{ ... }:

let
  ihp = builtins.fetchGit {
      url = "https://github.com/digitallyinduced/haskellframework.git";
      ref = "refs/tags/v0.8.0";
  };
in
  import ./build.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 = ./web;
  }

There’s a lot going on in the build file, but don’t fear: this is mainly copied from the IHP project to get all the dependencies needed, and then uses the build and install phases in mkDerivation to build binaries and copy them to the output.

# build.nix
{ compiler ? "ghc8103"
, ihp
, haskellDeps ? (p: [])
, otherDeps ? (p: [])
, projectPath ? ./.
}:

let
    pkgs = import "${toString projectPath}/Config/nix/nixpkgs-config.nix" { ihp = ihp; };
    ghc = pkgs.haskell.packages.${compiler};
    allHaskellPackages = ghc.ghcWithPackages
      (p: builtins.concatLists [ [p.haskell-language-server] (haskellDeps p) ] );
    allNativePackages = builtins.concatLists [
      (otherDeps pkgs)
      [pkgs.postgresql]
    ];
in
    pkgs.stdenv.mkDerivation {
        name = "attics";
        buildPhase = ''
          make -f ${ihp}/lib/IHP/Makefile.dist -B build/bin/RunOptimizedProdServer
          make -f ${ihp}/lib/IHP/Makefile.dist -B build/bin/Script/<your script name>
        '';
        installPhase = ''
          mkdir -p $out
          cp -r build/bin $out/bin

          mkdir -p $out/static
          cp -r ./static $out

          mkdir -p $out/Config
          cp -r ./Config $out
        '';
        dontFixup = true;
        src = (import <nixpkgs> {}).nix-gitignore.gitignoreSource [] projectPath;
        buildInputs = builtins.concatLists [[allHaskellPackages] allNativePackages];
        shellHook = "eval $(egrep ^export ${allHaskellPackages}/bin/ghc)";
    }

Dockerfile

Our Dockerfile uses a NixOS image to build the project using the Nix files we defined above. To start out:

FROM nixos/nix AS builder

# update packages
RUN nix-channel --update nixpkgs

# speed up compile time by using digitallyinduced's cachix cache
RUN nix-env -i cachix
RUN cachix use digitallyinduced

RUN mkdir -p /app/web

# since IHP won't be linked on our system, clone a local copy
RUN nix-env -i git
RUN git clone https://github.com/digitallyinduced/ihp.git /app/web/IHP

ADD web /app/web
WORKDIR /app

ADD build.nix .
ADD default.nix .

RUN nix-build

So far, this Dockerfile will build our project and create a symlink pointing to its place in the Nix store in the ./result directory. At this point, the image is pretty huge: about 10GB! This is mainly build dependencies like GHC. When shipping to production, we don’t want to keep these around. This is where the beauty of Nix comes in: we can use the nix-store command to get only the runtime dependencies for our derivation, and copy them over to a fresh image using Docker multi-stage builds.

Continuting on in the same Dockerfile…

# Store all runtime dependencies in a folder
RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure

# Start a new image
FROM scratch

# Copy over runtime dependencies, application code, and library
COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /app/result /app
COPY --from=builder /app/web/IHP /app/IHP

# certs for HTTPS requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

WORKDIR /app

CMD ["bin/RunProdServer"]

After building this Dockerfile, the result will be an image containing a bin folder, Config folder, static folder, and IHP folder, which is is all that is needed to run an IHP app. Use the DATABASE_URL environment variable to point to your PostgreSQL database, start the image, and your IHP app will be up and running.

docker run -p "8000:8000" -e "DATABASE_URL=..." <your image name>

Also, easily run any of the scripts you defined in the Nix build:

docker run -p "8000:8000" -e "DATABASE_URL=..." <your image name> bin/Script/<script name>

Next week, I’ll write about the complete setup on a NixOS server using all of the above – stay tuned :)

Building and publishing with GitHub actions

Today we’ll use Docker Hub to host our image, but you can use any container service you prefer. Refer to the GitHub Actions documentaion to find out how to authenticate with your service of choice, and then everything else should work the same.

Assuming you have Docker Hub repo for your project, and a Personal Access Token stored in your GitHub secrets, we can create a file .github/workflows/publish-docker-image.yml with the following contents:

name: Publish Docker image
on:
  push:
    branches: master

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    steps:
      -
        name: Checkout
        uses: actions/checkout@v2
      -
        name: Set up QEMU
        uses: docker/setup-qemu-action@v1
      -
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1
      -
        name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PAT }}
      -
        name: Build and push
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: <username>/<repo>:latest

After pushing this file to your GitHub repository, an action will be triggered which will build your application and deploy it to Docker Hub. Super easy, and best of all free!

That’s it!

Now you have a Docker image which contains a built IHP application. This can be deployed in any way you deploy Docker images. To keep costs low, I deployed my project on an AWS EC2 instance running NixOS along with a PostgreSQL database. Come back next Wednesday for a guide on getting that working, and as always please leave a comment with any questions or suggestions.

I’d also like to give huge thanks to Marco for his excellent post about using multi-stage builds and Nix over at his blog. I never would have been able to figure this out without that post!