skip to content
dz
5 min read Von nix eine Ahnung #1

NixOS auf Hetzner deployen

Read this in English →

Meinen Server beschreibe ich komplett in einem Git-Repo, und er baut sich daraus selbst neu. Möglich macht das NixOS, und in dieser Reihe zeige ich, wie ich dahin gekommen bin. Den Anfang macht eine leere Hetzner-VM, die per nixos-anywhere zu NixOS wird.

Meine Seiten und ein paar selbst gehostete Apps liefen vorher bei Hetzner über Coolify. Ich finde cool, wie einfach Coolify mir das Leben macht, gerade wenn ich nur mal einen Dienst ausprobieren möchte. Für meinen Server war mir das aber zu viel Abstraktion: Im Endeffekt will ich vor allem statische Webseiten ausliefern, und dafür braucht es nicht immer einen Container. NixOS stand schon länger auf meiner Liste, also habe ich einen zweiten Server angelegt und dort angefangen.

Bis ich ein stabiles Setup hatte, ist eine Weile vergangen, und die NixOS-Doku hat mir dabei nicht immer geholfen. Deshalb dokumentiere ich hier im Blog mit der Reihe "Von nix eine Ahnung", was ich warum gemacht habe.

Warum Hetzner? Ich nutze Hetzner schon lange, bin zufrieden und habe dort noch andere Sachen laufen. Geholfen hat mir außerdem die Anleitung Install NixOS on Hetzner Cloud im NixOS-Wiki. Sie ist die Grundlage für diesen Teil. Ich weiche an ein paar Stellen ab: Ich nutze einen Arm-Server und starte vor der Installation das Rescue-System (ich glaube, dass das früher so empfohlen wurde). Im Zweifel folgt ihr der offiziellen Dokumentation.

Installiert habe ich NixOS mit nixos-anywhere. Das Tool schaltet sich per SSH auf den Server, partitioniert die Platte, installiert NixOS und startet neu, und dafür brauche ich nur vier Dateien. Den Server selbst klicke ich in diesem Teil noch in der Console zusammen. Ehrlicherweise wäre es besser, ihn gleich mit OpenTofu als Code anzulegen, damit es nicht so viele manuelle Schritte gibt. Bei mir kam OpenTofu erst viel später ins Spiel.

Bevor es losgeht

Benötigt werden:

  • ein Hetzner-Cloud-Account und ein SSH-Key
  • Nix auf dem eigenen Rechner, mit aktivierten Flakes. Der Installer von Determinate Systems macht das von Haus aus, sonst braucht es experimental-features = nix-command flakes in der nix.conf.
  • Git

1. Server anlegen

In der Hetzner Console habe ich als Erstes einen neuen Server angelegt:

  1. Image: egal, es wird gleich überschrieben.
  2. Typ: ein Arm-Server (Reihe CAX). Die Größe hängt davon ab, was später darauf laufen soll, für dieses Tutorial ist sie egal. Mit x86 ändern sich hostPlatform, eventuell der Name der Netzwerkkarte und der Bootloader (dazu unten mehr).
  3. Den eigenen public SSH-Key hinterlegen
  4. Namen vergeben, IPv4- und IPv6-Adresse notieren

2. Das Repository

Ich initialisiere zunächst ein neues Git-Repo, denn Flakes sehen nur Dateien, die Git kennt.

infra/
├── flake.nix
└── hosts/hetzner/
    ├── configuration.nix
    ├── disko.nix
    └── hardware.nix

flake.nix

Die Flake beschreibt, woher NixOS und disko kommen, und definiert das System als nixosConfigurations.hetzner:

{
  description = "meine Infrastruktur";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
    disko = {
      url = "github:nix-community/disko";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, disko, ... }: {
    nixosConfigurations.hetzner = nixpkgs.lib.nixosSystem {
      modules = [
        disko.nixosModules.disko
        ./hosts/hetzner/hardware.nix
        ./hosts/hetzner/configuration.nix
      ];
    };
  };
}

nixos-26.05 ist ein stabiles Release. Das follows zwingt disko auf dieselbe nixpkgs-Version wie das System.

disko.nix

disko beschreibt die Partitionierung als Nix-Code. In diesem Beispiel: eine kleine EFI-Partition (ESP) für den Bootloader, der Rest ext4.

{
  disko.devices.disk.main = {
    type = "disk";
    device = "/dev/sda";
    content = {
      type = "gpt";
      partitions = {
        ESP = {
          size = "512M";
          type = "EF00";
          content = {
            type = "filesystem";
            format = "vfat";
            mountpoint = "/boot";
            mountOptions = [ "umask=0077" ];
          };
        };
        root = {
          size = "100%";
          content = {
            type = "filesystem";
            format = "ext4";
            mountpoint = "/";
          };
        };
      };
    };
  };
}

hardware.nix

Hier gebe ich an, dass die Hetzner-VM eine QEMU-Maschine ist und definiere den Bootloader.

{ lib, modulesPath, ... }: {
  imports = [
    (modulesPath + "/profiles/qemu-guest.nix")
    ./disko.nix
  ];

  boot.loader.grub = {
    enable = true;
    efiSupport = true;
    efiInstallAsRemovable = true;
    device = "nodev";
  };

  boot.initrd.availableKernelModules = [
    "virtio_pci" "virtio_scsi" "sd_mod"
  ];

  networking.useDHCP = lib.mkDefault true;

  nixpkgs.hostPlatform = "aarch64-linux";
}

efiInstallAsRemovable legt den Bootloader am EFI-Standardpfad ab. Die VM braucht dann keinen NVRAM-Eintrag, und der Boot übersteht auch eine Neuinstallation.

Das NixOS-Wiki zeigt die Konfiguration für x86: Dort steht im disko-Layout zusätzlich eine BIOS-Boot-Partition (EF02, 1 MB), und GRUB bekommt keine EFI-Optionen. Für Arm nennt das Wiki virtio_gpu in boot.initrd.kernelModules und console=tty in boot.kernelParams, falls die Console schwarz bleibt. Bei mir lief es ohne.

configuration.nix

Hier starte ich erst mal mit einer minimalen Konfiguration: SSH nur mit Key, Netzwerk, Zeitzone.

{
  system.stateVersion = "26.05";

  networking = {
    hostName = "hetzner";
    interfaces.enp1s0.ipv6.addresses = [{
      address = "2a01:4f8:xxxx:xxxx::1";
      prefixLength = 64;
    }];
    defaultGateway6 = {
      address = "fe80::1";
      interface = "enp1s0";
    };
  };

  time.timeZone = "Europe/Berlin";

  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      PermitRootLogin = "prohibit-password";
      KbdInteractiveAuthentication = false;
    };
  };

  users.users.root.openssh.authorizedKeys.keys = [
    "ssh-ed25519 AAAA... name@rechner"
  ];
}

Vier Stellen müsst ihr hier in jedem Fall anpassen:

  • Der SSH-Key ist die Zeile aus ~/.ssh/id_ed25519.pub. Fehlt sie, seid ihr ausgesperrt.
  • Die IPv6-Adresse steht in der Console beim Server. Hetzner vergibt ein /64-Netz, ich nehme die ::1. Das Gateway fe80::1 ist bei Hetzner immer dasselbe. IPv4 bekommt der Server per DHCP, das ist in hardware.nix eingestellt.
  • Die Netzwerkkarte heißt bei den Arm-Servern von Hetzner enp1s0. Im Rescue-System zeigt ip a, ob das bei euch auch zutrifft.
  • system.stateVersion ist die NixOS-Version der Erstinstallation und keine Update-Einstellung. Ich fasse sie nicht mehr an, auch nicht beim Wechsel auf einen neueren Release.

Den Port 22 muss ich für SSH nicht gesondert freigeben, das erledigt services.openssh für mich.

Dann erzeuge ich mit nix flake lock die flake.lock, die alle Versionen festhält, und committe:

git init
git add .
nix flake lock
git add flake.lock
git commit -m "feat: initial NixOS config"

3. Installieren

In der Hetzner Console aktiviere ich unter Rescue das Rescue-System (linux64), wähle meinen SSH-Key und starte den Server unter Power neu. Der Server bootet dann ein kleines Linux im RAM und lässt die Platte in Ruhe.

Dann gebt ihr Folgendes in euer Terminal ein:

nix run github:nix-community/nixos-anywhere -- \
  --flake .#hetzner \
  --target-host root@<ipv4> \
  --build-on-remote

--build-on-remote baut das System auf dem Server statt lokal. Auf einem Rechner ohne Arm ist das der einfachste Weg, sonst spart es Traffic.

Das dauert fünf bis zehn Minuten, danach startet der Server neu. Mehr zu den Optionen steht in der Quickstart-Anleitung von nixos-anywhere.

Ein Hinweis zu Arm: Laut README erwartet nixos-anywhere für den kexec-Start standardmäßig x86-64 und braucht für andere Architekturen ein eigenes kexec-Image. Bei mir lief der Befehl auf dem CAX ohne ein solches Image.

4. Prüfen

ssh root@<ipv4> "nixos-version"

Beim ersten Mal meldet SSH, dass sich der Host-Key geändert hat, weil dort jetzt ein anderes System läuft. Das kann schon beim Rescue-System passieren. Den alten Eintrag entfernt ihr mit ssh-keygen -R <ipv4>, danach klappt die Verbindung.

IPv6 prüfe ich dann nochmal getrennt, denn ein Tippfehler in der Adresse fällt sonst nirgends auf:

ssh -6 root@<ipv6> "nixos-version"

5. Änderungen ausrollen

Ab jetzt ändere ich den Server nur noch über das Repo. Ich bearbeite zum Beispiel configuration.nix und schicke die neue Konfiguration dann an den Server. Bei Änderungen am Netzwerk nehme ich zuerst test statt switch. Das aktiviert die Konfiguration, macht sie aber nicht zum Boot-Standard, und wenn ich mich aussperre, bringt ein Neustart über die Console den alten Stand zurück.

nix run nixpkgs#nixos-rebuild -- test \
  --flake .#hetzner \
  --target-host root@<ipv4> \
  --build-host root@<ipv4>

Läuft alles, wiederhole ich den Befehl mit switch. Das macht die Konfiguration zum Standard für den nächsten Boot. Jede Konfiguration bleibt als eigene Generation erhalten. Geht etwas schief, bringt ssh root@<ipv4> nixos-rebuild switch --rollback die vorige zurück. Und falls der Server nicht mehr per SSH erreichbar ist, lässt sich über die Hetzner Console im Bootmenü eine ältere Generation wählen.

Das Schöne daran: Wenn ihr einen zweiten Server braucht, reicht ein Aufruf von nixos-anywhere.