skip to content
dz
4 min read Clueless about Nix #4

NixOS: A static site with nginx and Let’s Encrypt

Auf Deutsch lesen →

My server has rebuilt itself from Git since part 2 and has known its secrets since part 3. This part is finally about serving a website of my own. My CI builds the site and uploads the result with rsync. For that it gets its own SSH login with access to exactly one directory.

My site is built with Eleventy. That makes no difference to the server, which only serves the finished files, and it works the same way with Hugo or any other generator. All it needs is nginx, a certificate from Let's Encrypt and a user that can upload files.

The finished configuration is in the nixos-series repository on the branch part-4-static-site-with-nginx. The comparison with part 3 shows all the changes. Instead of my domain it says example.org there, and I call the deploy user deploy-example-org.

A new configuration

As with the auto-rebuild, everything goes into a file of its own, which I add to the modules in flake.nix:

modules = [
  disko.nixosModules.disko
  sops-nix.nixosModules.sops
  ./hosts/hetzner/hardware.nix
  ./hosts/hetzner/configuration.nix
  ./hosts/hetzner/auto-rebuild.nix
  ./hosts/hetzner/website.nix
];

The blocks in the next steps all belong in this new file, website.nix. The complete file is at the end of the article.

Step 1: nginx and certificate

First the domain has to point to the server. The A record gets the server's IPv4 address, and if you also set an AAAA record, it gets the IPv6 address. Let's Encrypt checks over port 80 whether the domain belongs to the server, and it tries IPv6 first. A wrong AAAA record therefore makes the check fail even though the A record is correct. NixOS already has port 22 open, and 80 and 443 come on top:

networking.firewall.allowedTCPPorts = [ 80 443 ];

One virtualHosts entry is enough for the site:

services.nginx = {
  enable = true;
  recommendedGzipSettings = true;
  recommendedTlsSettings = true;

  virtualHosts."example.org" = {
    forceSSL = true;
    enableACME = true;
    root = "/var/www/example.org";
  };
};

security.acme = {
  acceptTerms = true;
  defaults.email = "me@example.org";
};

enableACME orders the certificate and renews it later on its own, and forceSSL redirects HTTP to HTTPS. Until Let's Encrypt has answered, NixOS uses a self-signed certificate so that nginx can start at all. If it stays that way, the cause is usually DNS, and journalctl -u acme-example.org shows where the error is.

Step 2: A login that may only upload

For the CI I create a user of its own with its own SSH key. This key can only start rrsync, a wrapper that restricts rsync to one directory:

users.groups.deploy-example-org = {};

users.users.deploy-example-org = {
  isSystemUser = true;
  group = "deploy-example-org";
  shell = pkgs.bash;
  openssh.authorizedKeys.keys = [
    ''restrict,command="${pkgs.rrsync}/bin/rrsync -wo -munge /var/www/example.org" ssh-ed25519 AAAA... deploy@example.org''
  ];
};

systemd.tmpfiles.rules = [
  "d /var/www/example.org 0755 deploy-example-org deploy-example-org -"
];

The key can only start rrsync: command= forces that on every connection, no matter what the client runs, and restrict blocks the terminal and port forwarding. -wo allows writing only, otherwise the key could also download files from the directory. With -munge, uploaded symlinks become unusable, so none of them can point out of the web directory.

Without shell = pkgs.bash, rsync aborts with protocol version mismatch - is your shell clean?. Unfortunately the message doesn't say that nologin is to blame. sshd doesn't run the command from command= itself but hands it to the user's shell, and for system users that is nologin by default. It only prints "This account is currently not available.", and that text ends up in front of the first byte of rsync. So the system user needs a real shell.

I create the key pair locally. The public part goes into the configuration, the private part into a secret of the CI:

ssh-keygen -t ed25519 -N '' -C deploy@example.org -f deploy_key

Step 3: Deploying from the CI

My site is built by a workflow on Codeberg (Forgejo Actions, the syntax is the same as GitHub Actions). The build runs before the deploy, and with Eleventy the result ends up in _site/ by default. Only the deploy step is shown here.

So that the CI doesn't blindly trust the server on first connect, the server's host key is stored as a second secret. The whole line from ssh-keyscan -t ed25519 example.org goes into the secret, not just the fingerprint. I compare the fingerprint with the one the server reports via ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub.

- name: Deploy
  env:
    DEPLOY_SSH_KEY: $
    SERVER_HOST_KEY: $
  run: |
    install -d -m 700 ~/.ssh
    printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
    chmod 600 ~/.ssh/deploy_key
    printf '%s\n' "$SERVER_HOST_KEY" > ~/.ssh/known_hosts
    rsync -r --delete --checksum \
      -e "ssh -i $HOME/.ssh/deploy_key \
          -o IdentitiesOnly=yes \
          -o StrictHostKeyChecking=yes \
          -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
      _site/ deploy-example-org@example.org:.

The target is . because rrsync makes the web directory the root. It rejects :/ with unsafe arg. --delete removes what is no longer in the new build. I need --checksum because -r doesn't transfer timestamps: otherwise rsync would consider every file changed and upload everything on every run. With --checksum it compares the contents. -r skips symlinks, and a static build usually has none.

If you are on GitHub, use the same step with runs-on: ubuntu-latest, and you can follow the series right now. I use Codeberg with a runner of my own that runs on the server itself. How that works will come in a later part. Until then, everyone who is on Codeberg like me is left with manual deployment, using the same command and the same key, just from your own machine:

rsync -r --delete --checksum -e "ssh -i deploy_key" \
  _site/ deploy-example-org@example.org:.

The first deploy

The order matters: the server has to know the new configuration before the CI uploads for the first time, otherwise the user doesn't exist yet. So I first commit and push the configuration, signed as in part 2, and then wait for the timer or start it with systemctl start nixos-auto-rebuild. After that I run the workflow.

curl -I https://example.org then shows whether HTTPS works with a valid certificate and whether the headers are right. Whether the site looks right, I check in the browser.

The complete website.nix

{ pkgs, ... }:
{
  networking.firewall.allowedTCPPorts = [ 80 443 ];

  services.nginx = {
    enable = true;
    recommendedGzipSettings = true;
    recommendedTlsSettings = true;

    virtualHosts."example.org" = {
      forceSSL = true;
      enableACME = true;
      root = "/var/www/example.org";
    };
  };

  security.acme = {
    acceptTerms = true;
    defaults.email = "me@example.org";
  };

  users.groups.deploy-example-org = {};

  users.users.deploy-example-org = {
    isSystemUser = true;
    group = "deploy-example-org";
    shell = pkgs.bash;
    openssh.authorizedKeys.keys = [
      ''restrict,command="${pkgs.rrsync}/bin/rrsync -wo -munge /var/www/example.org" ssh-ed25519 AAAA... deploy@example.org''
    ];
  };

  systemd.tmpfiles.rules = [
    "d /var/www/example.org 0755 deploy-example-org deploy-example-org -"
  ];
}

In the next part I will also install an application of my own on the server: the feed reader Miniflux with a Postgres database, reachable under a domain of its own.

Webmentions

1 repost
Prometheus