skip to content
dz
4 min read Von nix eine Ahnung #4

NixOS: Eine statische Seite mit nginx und Let’s Encrypt

Read this in English →

Mein Server baut sich seit Teil 2 selbst aus Git und kennt seit Teil 3 seine Secrets. In diesem Teil geht es (endlich) darum, wie ich eine eigene Webseite ausliefere. Meine CI baut die Seite und lädt das Ergebnis per rsync hoch. Dafür bekommt sie einen eigenen SSH-Zugang mit Zugriff auf genau ein Verzeichnis.

Meine Seite basiert auf Eleventy. Für den Server macht das keinen Unterschied, er liefert nur die fertigen Dateien aus, mit Hugo oder einem anderen Generator läuft es genauso. Dazu braucht er nginx, ein Zertifikat von Let's Encrypt und einen Benutzer, der Dateien hochladen kann.

Die fertige Konfiguration liegt im Repository nixos-series auf dem Branch part-4-static-site-with-nginx. Der Vergleich mit Teil 3 zeigt alle Änderungen. Statt meiner Domain steht dort example.org, und den Deploy-User nenne ich deploy-example-org.

Eine neue Konfiguration

Wie schon beim Auto-Rebuild kommt alles in eine eigene Datei, die ich in flake.nix zu den Modulen hänge:

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
];

Die Blöcke der nächsten Schritte gehören alle in diese neue Datei website.nix. Am Ende des Artikels findet ihr die komplette Datei.

Schritt 1: nginx und Zertifikat

Zuerst muss die Domain auf den Server zeigen. Der A-Eintrag bekommt die IPv4-Adresse des Servers, und wer auch einen AAAA-Eintrag setzt, trägt dort die IPv6-Adresse ein. Let's Encrypt prüft über Port 80, ob die Domain zum Server gehört, und probiert es zuerst über IPv6. Ein falscher AAAA-Eintrag lässt die Prüfung deshalb scheitern, obwohl der A-Eintrag stimmt. Port 22 hat NixOS schon offen, 80 und 443 kommen dazu:

networking.firewall.allowedTCPPorts = [ 80 443 ];

Für die Seite reicht ein virtualHosts-Eintrag:

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 = "ich@example.org";
};

enableACME bestellt das Zertifikat und erneuert es später von allein, forceSSL leitet HTTP auf HTTPS um. Bis Let's Encrypt geantwortet hat, nimmt NixOS ein selbstsigniertes Zertifikat, damit nginx überhaupt startet. Bleibt es dabei, liegt es meistens am DNS, und journalctl -u acme-example.org zeigt, wo der Fehler liegt.

Schritt 2: Ein Zugang, der nur hochladen darf

Für die CI lege ich einen eigenen Benutzer mit eigenem SSH-Key an. Mit diesem Key lässt sich ausschließlich rrsync starten, ein Wrapper, der rsync auf ein Verzeichnis beschränkt:

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 -"
];

Der Key kann nur rrsync starten: command= erzwingt das bei jeder Verbindung, egal was der Client aufruft, und restrict sperrt Terminal und Port-Forwarding. -wo erlaubt nur Schreiben, sonst könnte der Key auch Dateien aus dem Verzeichnis herunterladen. Mit -munge werden hochgeladene Symlinks unbrauchbar, damit keiner aus dem Webverzeichnis herauszeigen kann.

Ohne shell = pkgs.bash bricht rsync mit protocol version mismatch - is your shell clean? ab. Dass nologin schuld ist, steht leider nicht in der Meldung. sshd führt den Befehl aus command= nämlich nicht selbst aus, sondern übergibt ihn an die Shell des Benutzers, und bei Systembenutzern ist das standardmäßig nologin. Das gibt nur "This account is currently not available." aus, und dieser Text landet vor dem ersten Byte von rsync. Der Systembenutzer braucht also eine echte Shell.

Das Schlüsselpaar erzeuge ich lokal. Der öffentliche Teil gehört in die Konfiguration, der private in ein Secret der CI:

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

Schritt 3: Deploy aus der CI

Meine Seite baut ein Workflow bei Codeberg (Forgejo Actions, die Syntax ist die von GitHub Actions). Der Build läuft vor dem Deploy, und bei Eleventy landet das Ergebnis standardmäßig in _site/. Hier steht nur der Deploy-Schritt.

Damit die CI beim ersten Verbinden nicht blind dem Server vertraut, liegt dessen Host-Key als zweites Secret bereit. Ins Secret gehört die ganze Zeile aus ssh-keyscan -t ed25519 example.org, nicht nur der Fingerprint. Den Fingerprint vergleiche ich mit dem, den der Server über ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub nennt.

- 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:.

Das Ziel ist ., weil rrsync das Webverzeichnis zur Wurzel macht. :/ lehnt es mit unsafe arg ab. --delete entfernt, was es im neuen Build nicht mehr gibt. --checksum brauche ich, weil -r keine Zeitstempel überträgt: Rsync hielte sonst jede Datei für geändert und lüde bei jedem Lauf alles hoch. Mit --checksum vergleicht es die Inhalte. Symlinks überspringt -r, ein statischer Build hat meistens keine.

Wer auf GitHub ist, nimmt denselben Schritt mit runs-on: ubuntu-latest und kann der Serie damit schon jetzt folgen. Ich nutze Codeberg mit einem eigenen Runner, der auf dem Server selbst läuft. Wie das geht, kommt in einem späteren Teil. Bis dahin bleibt allen, die wie ich bei Codeberg sind, nur das manuelle Deployment, mit demselben Befehl und demselben Key, nur vom eigenen Rechner:

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

Der erste Deploy

Die Reihenfolge zählt: Der Server muss die neue Konfiguration kennen, bevor die CI zum ersten Mal hochlädt, sonst gibt es den Benutzer noch nicht. Ich committe und pushe also zuerst die Konfiguration, signiert wie in Teil 2, und warte auf den Timer oder starte ihn mit systemctl start nixos-auto-rebuild. Danach lasse ich den Workflow laufen.

curl -I https://example.org zeigt dann, ob HTTPS mit gültigem Zertifikat läuft und die Header stimmen. Ob die Seite richtig aussieht, sehe ich im Browser.

Die komplette 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 = "ich@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 -"
  ];
}

Im nächsten Teil installiere ich auf dem Server dann auch eine eigene Anwendung: den Feedreader Miniflux mit Postgres-Datenbank, erreichbar unter einer eigenen Domain.