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!
- Go follow the instructions on their website to get their CLI setup.
- Run
flyctl apps create
and create an app named “app” - 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)