Deploying an IHP project to Fly.io

Learning Haskell the conventional way can be difficult, so I’m going to recommend a different path, you can dive in head-first with the “Ruby on Rails” of Haskell: IHP.

Haskell is awesome. If you haven’t used it, you should give it a shot. There are a few paths to learning Haskell. You could read the book, but that’s >1000 pages of knowledge that will probably scare you off if you’re just getting into it. So I’m going to recommend a different path, you can dive in head-first with the “Ruby on Rails” of Haskell: IHP.

After you’ve got everything working, and you’ve made your first Haskell web application. You’ll probably be thinking to yourself “they’ve been talking up nix so much, so how do I deploy this?”. I’ve got good news. It’s actually very easy to create an optimized, many-layered, reproducible docker image with nix, and I’m going to show you how. Then we’re going to:

  • Deploy it for free on fly.io
  • Setup CI/CD to deploy every commit automatically with github actions.

Making an IHP docker image

First things first, you’re going to follow the IHP setup instructions. Come back when you’ve got everything working.

Welcome back!

At the time of me writing this, in your newly created IHP project, you’ll find a default.nix file with the following contents:

let
    ihp = builtins.fetchGit {
        url = "https://github.com/digitallyinduced/ihp.git";
        ref = "refs/tags/v0.19.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

With that, if you were to run the following command in the project directory:

nix-build —option sandbox false

you’d end up with a result/bin folder in your current directory containing a binary to run your IHP project. You might be thinking “Cool! Now how do I ship this on Fly.io?”

We’ll get there, but first I’m going to explain what each of these blocks are doing:

ihp = builtins.fetchGit {
    url = "https://github.com/digitallyinduced/ihp.git";
    ref = "refs/tags/v0.19.0";
};

This block downloads a copy of the IHP git repo, specifically the 0.19.0 tag and stores it in the ihp variable.

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 = ./.;
};

This block imports a nix function from the IHP git repo we just fetched, and passes some named arguments to the function. ihp is just passing in the repo, haskellDeps is a list of haskell dependencies that we need to build the project, otherDeps is a list of native dependencies we need to build the project, and projectPath is the path to copy all your files from.

To facilitate making our docker image, we need to make a new file app.nix that defines a function that returns a similar value to our current default.nix

# app.nix

{ additionalNixpkgsOptions ? {}, optimized ? false }:

let
    ihp = builtins.fetchGit {
        url = "https://github.com/digitallyinduced/ihp.git";
        ref = "refs/tags/v0.19.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
        ];
        additionalNixpkgsOptions = additionalNixpkgsOptions; 
        projectPath = ./.;
        optimized = optimized;
    };
in
    haskellEnv

With that file created, we can update our default.nix file like so:

# default.nix

let
    haskellEnv = import ./app.nix {};
in
    haskellEnv

Now, for the good part, let’s make a docker image. Create a new file docker.nix like so:

# docker.nix

{ localPkgs ? import <nixpkgs> {}
, imagePkgs ? import <nixpkgs> { system = "x86_64-linux"; }
, ihpApp ? import ./app.nix { 
    additionalNixpkgsOptions = { system = "x86_64-linux"; }; 
    optimized = true; 
  }
}:

localPkgs.dockerTools.buildLayeredImage {
  name = "app";
  contents = [ imagePkgs.cacert imagePkgs.iana-etc imagePkgs.openssl ];
  config = {
    Cmd = [ "${ihpApp}/bin/RunProdServer" ];
    WorkingDir = "${ihpApp}/lib";
    ExposedPorts = {
      "8000" = {};
    };
  };
  maxLayers = 128;
}

You can now, if you’re running linux, you can run the following command to create an optimized linux build of the IHP project and load it into docker:

docker load < $(nix-build ./docker.nix --option sandbox false)

If you have the misfortune of using Windows or MacOS, you’ll need to use the following Dockerfile to build the image:

# This file lets you build the image on windows/mac
# Run `docker build . -t app`
# Then run `docker run -d -t app` to run the image
# Then run `docker ps` to get the hash of the running container
# Then use that and run `docker cp -L TheContainerHash:/app/result ./app.tar.gz`
# Then run `docker load < ./app.tar.gz`

FROM nixos/nix

RUN nix-channel --update

WORKDIR /app

RUN nix-env -iA cachix -f https://cachix.org/api/v1/install
RUN cachix use digitallyinduced

ADD . .

RUN nix-build ./docker.nix --option sandbox false

Deploying on Fly.io

Now we’ll get started with deploying to fly.io!

  1. Go follow the instructions on their website to get their CLI setup.
  2. Run flyctl apps create and create an app named “app”
  3. Follow the instructions to create and attach a postgres instance to your app

Make a fly.toml file in your project like so:

# fly.toml file generated for app on 2022-06-10T20:32:18-07:00

app = "app"

kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[build]
  image = "app"

[env]
  PORT = "8000"
  IHP_REQUEST_LOGGER_IP_ADDR_SOURCE = "FromHeader"
  IHP_BASEURL = "https://YOURURLHERE.com"

[experimental]
  allowed_public_ports = []
  auto_rollback = true

[[services]]
  http_checks = []
  internal_port = 8000
  processes = ["app"]
  protocol = "tcp"
  script_checks = []

  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

  [[services.ports]]
    force_https = true
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

You’ll need to create a secret IHP_SESSION_SECRET , I used pwgen -s 128 1 to generate one:

flyctl secrets set IHP_SESSION_SECRET=$(pwgen -s 128 1)

Now find the image in your local docker

docker image ls

and finally run

flyctl deploy -i app:theTagFromThePreviousCommand

Deploying automatically with Github Actions

If you want to have it automatically deployed to fly every commit in your repo, you’ll need to go create an API token and add it as a secret in your repo settings, then you can create .github/workflows/deploy.yml like so

name: "Build Image"
on:
  pull_request:
  push:
env:
  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
  # This should be of the form postgresql://username:password@localhost/db_name
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      - uses: cachix/install-nix-action@v17
        with:
          nix_path: nixpkgs=channel:nixos-22.05
      - uses: cachix/cachix-action@v10
        with:
          name: digitallyinduced
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: docker load < $(nix-build ./docker.nix --option sandbox false)
      - run: flyctl proxy 5432 -a app-db &
      # Give 10 seconds for the proxy to start up, 5 seconds would probably work too
      - run: sleep 10 && nix-shell --run migrate
      - run: flyctl deploy -i app:$(docker image ls "app:*" -a --format 'table {{.Tag}}' | sed -sn 2p)

Subscribe to Some writings about software

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe