Hakyll is a static site generator written in Haskell. This tutorial will give you first a summary on how to generate the base Hakyll site, second I will show you how to connect it with Nix and NixOS and in a last step we serve all of this via HAProxy.
Initialize the Project
First, of course, we have to initialize our project. In this case I
created a repository named website
on GitHub, cloned the empty
repository and started from there. You need to have hakyll
installed in order to have the hakyll-init
command available.
$ git clone git@github.com:thomasbach-dev/website.git
$ cd website
$ hakyll-init .
$ git add .
$ git commit -m 'initialized hakyll via hakyll-init'
Next, I generate website.nix
(rename it to your projects name):
$ cabal2nix . > website.nix
We reference that file in our shell.nix
:
{ nixpkgs ? import <nixpkgs> {}, compiler ? "default" }:
let
inherit (nixpkgs) pkgs;
haskellPackages = if compiler == "default"
then pkgs.haskellPackages
else pkgs.haskell.packages.${compiler};
drv = haskellPackages.callPackage ./website.nix { };
in
if pkgs.lib.inNixShell then drv.env else drv
Now we can jump into a nix-shell
and run the site generator
called site
for the first time:
$ nix-shell
$ cabal new-run site
# [Compilation output]
Missing: COMMAND
Usage: site [-v|--verbose] COMMAND
site - Static site compiler created with Hakyll
Available options:
-h,--help Show this help text
-v,--verbose Run in verbose mode
Available commands:
build Generate the site
check Validate the site output
clean Clean up and remove cache
deploy Upload/deploy your site
preview [DEPRECATED] Please use the watch command
rebuild Clean and build again
server Start a preview server
watch Autocompile on changes and start a preview server.
You can watch and recompile without running a server
with --no-server.
So, let’s give it a COMMAND
:
$ cabal new-run site -- watch
This will build your site and watch for changes –everytime a file changes it will rebuild the site. Additionally, it starts a server which provides the site. Start up your browser of choice and guide it to http://127.0.0.1:8000.
Start producing content, adapting the default templates, etc.…
Define a nix derivation and plug it into NixOS
The next step will be to produce a derivation which nix can build
for us. Add a file default.nix
with the following content,
adapted to your needs of course:
{ nixpkgs ? import <nixpkgs> {}, compiler ? "default"}:
let
inherit (nixpkgs) pkgs;
haskellPackages = if compiler == "default"
then pkgs.haskellPackages
else pkgs.haskell.packages.${compiler};
website = haskellPackages.callPackage ./website.nix {};
in
{
nixpkgs.stdenv.mkDerivation name = "thomas-bach.dev-website";
buildInputs = [ website ];
src = ./.;
buildPhase = ''
site build
'';
installPhase = ''
mkdir $out
cp -R _site/* $out
'';
}
This instructs nix to first build the Haskell executable of the
package called site
, then, in the build phase, to call that
executable with the command build
and finally, in the install
phase, to copy the generated files into the store. With this file
in place you can run nix build
in the directory and you should
get a symbolic link result
pointing to the nix store containing
only the static website.
Now, to make this usable to NixOS we first need a place where it can fetch the sources from. In my case I want this to be GitHub as I will publish the code there anyway. To make this a bit more easier I tag the commit, I want to publish with.
$ git tag 1
$ git push --tags
$ nix-prefetch-url --unpack https://github.com/thomasbach-dev/website/archive/1.tar.gz
0df3j462103p8hzsa08pjfk5idipwg7nfgam1am4vyjk2q45ywlg
In your configuration.nix
you can now define a package -e.g. in a
let-expression- like this:
-dev = import (pkgs.fetchFromGitHub {
thomasbachowner = "thomasbach-dev";
repo = "website";
rev = "1";
sha256 = "0df3j462103p8hzsa08pjfk5idipwg7nfgam1am4vyjk2q45ywlg";
}) { nixpkgs = pkgs; };
and add that package to your environment.systemPackages
list. In
my case this gives me a store entry with the suffix
thomas-bach.dev-website
which contains just the static files of
the site.
Serve it with HAProxy
Let’s plug this into HAProxy as a final step! As HAProxy is basically just a proxy, we need a little trick to make it serve static files: we define a LUA function which does the job for us.
{config, pkgs, ...}:
let
# https://discourse.haproxy.org/t/how-do-i-serve-a-single-static-file-from-haproxy/32/11
serveFile = builtins.toFile "serve-file.lua" ''
core.register_service("serve-file", "http", function(applet)
local docroot
local location
local file
local retval
local response
local extension
if(applet.path == nil or applet.headers["x-lua-loadfile-docroot"] == nil or applet.headers["x-lua-loadfile-docroot"][0] == "") then
retval = 500
response = "Internal Server Error"
else
docroot = applet.headers["x-lua-loadfile-docroot"][0]
location = applet.path
if(location == "" or location == "/") then
location = "/index.html"
end
file = io.open(docroot .. location, "r")
if(file == nil) then
retval = 404
response = "File Not Found"
else
retval = 200
response = file:read("*all")
file:close()
end
end
extension = string.match(location, ".(%w+)$")
if extension == "css" then applet:add_header("content-type", "text/css")
elseif extension == "gif" then applet:add_header("content-type", "image/gif")
elseif extension == "htm" then applet:add_header("content-type", "text/html")
elseif extension == "html" then applet:add_header("content-type", "text/html")
elseif extension == "ico" then applet:add_header("content-type", "image/x-icon")
elseif extension == "jpg" then applet:add_header("content-type", "image/jpeg")
elseif extension == "jpeg" then applet:add_header("content-type", "image/jpeg")
elseif extension == "js" then applet:add_header("content-type", "application/javascript; charset=UTF-8")
elseif extension == "json" then applet:add_header("content-type", "application/json")
elseif extension == "mpeg" then applet:add_header("content-type", "video/mpeg")
elseif extension == "png" then applet:add_header("content-type", "image/png")
elseif extension == "txt" then applet:add_header("content-type", "text/plain")
elseif extension == "xml" then applet:add_header("content-type", "application/xml")
elseif extension == "zip" then applet:add_header("content-type", "application/zip")
end
applet:set_status(retval)
if(response ~= nil and response ~= "") then
applet:add_header("content-length", string.len(response))
end
applet:start_response()
applet:send(response)
end)
'';
thomasbach-dev = import (pkgs.fetchFromGitHub {
owner = "thomasbach-dev";
repo = "website";
rev = "1";
sha256 = "0df3j462103p8hzsa08pjfk5idipwg7nfgam1am4vyjk2q45ywlg";
}) { nixpkgs = pkgs; };
in {
services.haproxy.enable = true;
systemd.services."copy-site".script = ''
rm -rf /var/lib/haproxy/thomasbach-dev
cp -r ${thomasbach-dev}/ /var/lib/haproxy/thomasbach-dev
'';
systemd.services.haproxy.requires = [ "copy-site.service" ];
services.haproxy.config = ''
global
lua-load ${serveFile}
chroot /var/lib/haproxy
user haproxy
group haproxy
defaults
mode http
option httplog
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
backend www-thomasbach-dev
mode http
http-request set-header X-LUA-LOADFILE-DOCROOT /thomasbach-dev
http-request use-service lua.serve-file
frontend http-in
bind *:80
bind :::80
acl thomasbach-dev hdr_beg(host) -i thomasbach.dev
http-request redirect code 301 location http://www.%[hdr(host)]%[capture.req.uri] if thomasbach-dev
acl www-thomasbach-dev hdr_beg(host) -i www.thomasbach.dev
use_backend www-thomasbach-dev if www-thomasbach-dev
'';
};
}
Note how we reused the package definition given
above. Additionally, the configuration does a forward from
http://thomasbach.dev
to http://www.thomasbach.dev
. You might
not want that. Also note that as we tell HAProxy to chroot into
/var/lib/haproxy
we cannot simply point it to the static pages in
the store. Therefor I added a little systemd-script which copies
the files over. This is far from ideal, but does the trick for now.