Deploying a fully automated Nix-based static website

Posted on 21 November, 2019

My website has changed a bit over time. It was initially a WordPress blog, contorted to do double-duty as a folio for my design work. More recently it's been a static site, first generated by Metalsmith and later Hakyll. About a month ago I started to feel that hosting my site on Github pages was a little disconnected. I know this sounds odd, but I wanted to have a more active role in its deployment and take more responsibility for how it's run. Silly, right?

Well, now here we are. If you're reading this at bradparker.com then you're getting the full experience. It's being served from a Digital Ocean droplet, running NixOs which has been configured to run a Warp web-server which is serving a static site built by Hakyll.

While researching how I might set all of this up I came across a great article on the Digital Ocean blog entitled Deploying a Fully-automated Git-based Static Website in Under 5 Minutes. The result seemed almost exactly what I wanted, a Github-pages-like experience but managed by me. The only thing that I felt it needed was a slightly more declarative approach.

First, a Droplet

An important attribute of this setup for me is reproducibility. I should be able to stand a new server up in functionally same state as the current one by running one command. I don't want to have to access to server directly and configure anything by running a sequence of steps. This includes things like the security best practices outlined in another DigitalOcean blog article referenced in the aforementioned.

To achieve this my server is running NixOs, a "completely declarative" operating system built on top of the Nix package manager. Nix is one of those very powerful and very mysterious things, it's hard to fully explain because it can do so much. I use it as much as I can get away with, it configures my work and personal machines and now the server which hosts my website.

Chris Martin has a great post which outlines how to get a DigitalOcean droplet running NixOs. It is possible to extend those instructions slightly and get something more automated. To do this I took some advice from the NixOs Infect README and made use of the fact that DigitalOcean images come with cloud-init installed. This makes it possible to run NixOs infect on the initial boot of a droplet without ever having to SSH into it. Using doctl you can create a droplet and have NixOs installed on it with one command.

#!/usr/bin/env bash

set -e

doctl compute droplet create \
  "bradparker.com" \
  --size s-1vcpu-3gb \
  --image ubuntu-16-04-x64 \
  --region sgp1 \
  --user-data "$(cat <<-EOF
    #cloud-config
    write_files:
    - path: /etc/nixos/host.nix
      permissions: '0644'
      content: |
        {pkgs, ...}:
        {
          # TODO: ... everything
        }
    runcmd:
      - curl https://raw.githubusercontent.com/elitak/nixos-infect/master/nixos-infect | PROVIDER=digitalocean NIXOS_IMPORT=./host.nix NIX_CHANNEL=nixos-19.09 bash 2>&1
    EOF
    )"

Now, the above command would not result in anything very useful. It's not running my website, and if I wanted to get the rest working I wouldn't even be able to SSH in to continue manually. I'm going to need a user who can SSH in and run commands as root if needs be.

{ config, pkgs, ... }:
{
  services.openssh = {
    passwordAuthentication = false;
    permitRootLogin = "no";
  };

  users.mutableUsers = false;

  users.users.brad = {
    isNormalUser = true;
    hashedPassword = "$1$cmckqoXC$eBya/upETQKFbInZPz5y8.";
    home = "/home/brad";
    description = "Brad Parker";
    extraGroups = [
      "wheel"
    ];
    openssh.authorizedKeys.keys = [
      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQD8pPHsHkSNeX+YTVfbmrMltnWs+6dejWClomo2jBQuSv93WAzChWA1lhh8rUo9yxlud4FSV2iJ+3qQ6lpxgt8Dd9rS+xa8SS1rga/nTG5eqJqfq1fDyKXF+TpbHbNO09QdWZjRTcvv1DNCd51FyWUsFojECjoY0KmyjxJCmVyKgdvWjW/9DHiP0a1MS1ILtREr873D5SiQlKKj8T1AaVs7tToZNtZWxE2U4L4ibWYiWoJnVMi85t0nGj2NNBsVJsYhAb5fh5Uj5La9R3nR7RrkCLCHOKzuZu6VkWIQcObFCgb5G80DDt9Vp8uiNDCMwMLZA2HPi4CpGjWEfgHbvYEbgRNZIQcJhid12HscjEChLN4uvdp+9TiQwFsTm3kSBgflERFOdX4qbJMy/XQz8FEgUb2E8/lo3P3DMUeYhNXNGO6jmBvknNBv6XXjhhOFBa9xmT1jnjakSpKBWvICIWqLFmi8MByldqTW7oJKNpDH4qI4ML0goPaZ9ketlGs8PUwr1d4bCR/Dm79VTuiiUNw+i0J4sQrs2ufkEtuBQfBmHywVhl/sDS/3A7vUZWZ+2Cb+9qssAGjEy424anWRdkFx/x+m+9Zreb2tkXDlofEd18fCNbqAZzVTYAoDlQUmen1IJOEhWPpC724p3wyv8Y2SN1pMunPQ7ThmuPEB/hOSEQ== hi@bradparker.com"
    ];
  };
}

The above file describes a Nix module which will create a user able to log into the machine using SSH. I've also made sure that only key pairs can be used for SSH authentication and it's not possible to log in directly as the root user. In order to run commands as root I first have to SSH in and then elevate my privileges using sudo. In order to do this I've given my user a password by providing the hashedPassword option. I generated a value for this by using openssl on my local machine.

$ openssl passwd -1
Password: <Type a password>
Verifying - Password: <Type it again>
$1$JO1.bEGj$69.Gdj1/utBcmwRJYGu741

The mutableUsers option means that users or groups created with useradd or groupadd won't be persisted when Nix rebuilds the system.

Now, I'm going to want to update an existing server after it's been created from time to time. The way I do this is by modifying a local host.nix file, rsync-ing it up onto the server, using ssh to move it to /etc/nixos/ as root and running nixos-rebuild switch.

#!/usr/bin/env bash

set -exo pipefail

rsync host.nix bradparker.com:host.nix
ssh -t bradparker.com \
  'sudo mv host.nix /etc/nixos/host.nix && sudo nixos-rebuild switch'

The whole server is administered using a local host.nix file and the above script.

Next, a static site

For the static site I wanted the "push to deploy" experience of Github pages. To achieve this firstly I built a Nix derivation which describes, in full, how to build my static site. That derivation depends on another one which describes, in full, how to build the builder for my site. As an aside, herein lies one of the killer features of Nix over similar tools: it composes. I'll only need to say that I want the site built and builder will be brought along for the ride. Once I had a derivation for my site I then defined a systemd service to build it every five minutes.

{ options, lib, config, pkgs, ... }:
let
  serverName = "bradparker.com";
  webRoot = "/var/www/${serverName}";

  serviceConfig = config.services."${serverName}";
  options = {
    enable = lib.mkEnableOption "${serverName} service";
  };
in
  {
    options.services.${serverName} = options;
    config = lib.mkIf serviceConfig.enable {
      systemd.services."source-${serverName}" = {
        description = ''
          https://${serverName} source
        '';
        serviceConfig = {
          Type = "oneshot";
        };
        startAt = "*:0/5";
        path = with pkgs; [ nix gnutar gzip curl jq ];
        script = ''
          set -ex

          rev=$(curl https://api.github.com/repos/bradparker/bradparker.com/git/ref/heads/source | jq -r .object.sha)
          result=$(nix-build https://github.com/bradparker/bradparker.com/archive/$rev.tar.gz -A bradparker-com.site)

          ln -sfT $result${webRoot} ${webRoot}
        '';
      };
    };
  }

I curl the Github API to get the revision at the HEAD of my "source" branch, and call nix-build directly on the archive tarball for that revision. The web root symlink is then updated to point at the result. Fetching the latest revision rather than just using the tarball URL for the "source" branch means that Nix can still cache aggressively.

To make it possible to update my host configuration and this service configuration separately I've put the service configuration in a separate file (module.nix) that I fetch and import in host.nix.

{ config, pkgs, ... }:
let
  bradparker-source = builtins.fetchTarball {
    url = https://github.com/bradparker/bradparker.com/archive/355cf315becbf39ad7d3d032a88fe913ea0c4565.tar.gz;
  };
in
{
  imports = ["${bradparker-source}/module.nix"];

  # See above for user config.

  services."bradparker.com".enable = true;
}

Finally, a web server

At this stage the sensible thing to have done would have been to use the excellent Nginx NixOs module. I'd have auto-renewing SSL certificates (ACME) and a speedy web server in six lines of code. But I didn't do that, I wanted to see what it'd be like to use the Haskell web-server Warp to serve my site, even including painless HTTPS.

I needed to write some Haskell for both the application to serve the static site and one to serve ACME challenges in order to get auto-renewing certificates. Being that I love writing Haskell this was OK with me.

With those written I then needed some systemd services to run them. One for the static site.

{ options, lib, config, pkgs, ... }:
let
  package = import ./.;
  server = package.bradparker-com.server;

  serverName = "bradparker.com";
  webRoot = "/var/www/${serverName}";

  serviceConfig = config.services."${serverName}";
  options = {
    enable = lib.mkEnableOption "${serverName} service";
  };
in
  {
    options.services.${serverName} = options;
    config = lib.mkIf serviceConfig.enable {
      # ... Source service definition

      systemd.services.${serverName} = {
        wantedBy = [ "multi-user.target" ];
        wants = [
          "acme-${serverName}.service"
          "acme-challenge-${serverName}.service"
        ];
        requires = ["source-${serverName}.service"];
        script = ''
          ${server}/bin/server \
            --port 443 \
            --directory /var/www/${serverName} \
            --https-cert-file /var/lib/acme/${serverName}/fullchain.pem \
            --https-key-file /var/lib/acme/${serverName}/key.pem
        '';
        description = ''
          https://${serverName}
        '';
        serviceConfig = {
          KillSignal="INT";
          Type = "simple";
          Restart = "on-abort";
          RestartSec = "10";
        };
      };
    };
  }

Note I've put all the Nix derivations into a set so I can import them as a whole.

And a service for the ACME challenges.

{ options, lib, config, pkgs, ... }:
let
  package = import ./.;
  acme = package.bradparker-com.acme;

  serverName = "bradparker.com";
  acmeWebRoot =  "/var/lib/acme/acme-challenge";

  serviceConfig = config.services."${serverName}";
  options = {
    enable = lib.mkEnableOption "${serverName} service";
  };
in
  {
    options.services.${serverName} = options;
    config = lib.mkIf serviceConfig.enable {
      # ... Source service definition
      # ... Static site server service definition

      systemd.services."acme-challenge-${serverName}" = {
        wantedBy = [ "multi-user.target" ];
        script = ''
          ${acme}/bin/acme \
            --port 80 \
            --directory ${acmeWebRoot}
        '';
        description = ''
          The acme challenge server
        '';
        serviceConfig = {
          KillSignal="INT";
          Type = "simple";
          Restart = "on-abort";
          RestartSec = "10";
        };
      };
    };
  }

To go with that I needed to enable and configure the ACME service itself.

{ options, lib, config, pkgs, ... }:
let
  serverName = "bradparker.com";
  acmeWebRoot =  "/var/lib/acme/acme-challenge";

  serviceConfig = config.services."${serverName}";
  options = {
    enable = lib.mkEnableOption "${serverName} service";
  };
in
  {
    options.services.${serverName} = options;
    config = lib.mkIf serviceConfig.enable {
      # ... Source service definition
      # ... Static site server service definition
      # ... ACME challenge server service definition

      security.acme.certs = {
        ${serverName} = {
          email = "hi@bradparker.com";
          webroot = "${acmeWebRoot}";
          extraDomains = { "bradparker.com.au" = null; };
          postRun = "systemctl restart ${serverName}.service";
        };
      };
    };
  }

Finally, I needed to open the default HTTP and HTTPS ports in NixOs' firewall. I did this in the host.nix file rather than the module file.

{ config, pkgs, ... }:
let
  # ... Fetching source
in
{
  # ... User config, importing the module

  networking.firewall.allowedTCPPorts = [ 80 443 ];

  # ... Enabling the service
}

Hey, presto

That's it. I'm pretty happy with the result thus far and will likely continue to tweak it. If you haven't yet checked out Nix or NixOs I encourage you to do so, I think it's great.