Deploying NixOS to Hetzner
I describe my server entirely in a Git repo, and it rebuilds itself from it. NixOS makes that possible, and in this series I show how I got there. It starts with an empty Hetzner VM that becomes NixOS through nixos-anywhere.
My sites and a few self-hosted apps used to run on Hetzner through Coolify. I like how easy Coolify makes things for me, especially when I just want to try out a service. For my server it was too much abstraction, though: in the end I mostly want to serve static websites, and that does not always need a container. NixOS had been on my list for a while, so I set up a second server and started there.
It took a while until I had a stable setup, and the NixOS documentation did not always help. So in this blog I document what I did and why, in a series called "Clueless about Nix". The German original, "Von nix eine Ahnung", plays on "nix", which is colloquial for "nothing".
Why Hetzner? I have been using Hetzner for a long time, I am happy with it and I run other things there. I was also helped by the guide Install NixOS on Hetzner Cloud in the NixOS wiki. It is the basis for this part. I deviate in a few places: I use an Arm server and start the rescue system before the installation (I believe that used to be the recommended way). When in doubt, follow the official documentation.
I installed NixOS with nixos-anywhere. The tool connects to the server over SSH, partitions the disk, installs NixOS and reboots, and all it needs from me is four files. In this part I still click the server together in the console. Honestly, it would be better to create it as code with OpenTofu right away, so there are fewer manual steps. For me, OpenTofu only came much later.
Before you start
You need:
- a Hetzner Cloud account and an SSH key
- Nix on your own machine, with flakes enabled. The Determinate Systems installer does that out of the box, otherwise add
experimental-features = nix-command flakesto yournix.conf. - Git
1. Create the server
In the Hetzner Console I first created a new server:
- Image: any, it gets overwritten right away.
- Type: an Arm server (CAX series). The size depends on what will run on it later, for this tutorial it does not matter. With x86,
hostPlatform, possibly the name of the network card and the bootloader change (more on that below). - Add your own public SSH key
- Pick a name and note the IPv4 and IPv6 address
2. The repository
I start by initializing a new Git repo, because flakes only see files that Git knows about.
infra/
├── flake.nix
└── hosts/hetzner/
├── configuration.nix
├── disko.nix
└── hardware.nixflake.nix
The flake describes where NixOS and disko come from and defines the system as nixosConfigurations.hetzner:
{
description = "my infrastructure";
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 is a stable release. The follows forces disko onto the same nixpkgs version as the system.
disko.nix
disko describes the partitioning as Nix code. In this example: a small EFI partition (ESP) for the bootloader, the 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
Here I state that the Hetzner VM is a QEMU machine and define the 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 puts the bootloader at the default EFI path. The VM then needs no NVRAM entry, and booting survives a reinstall.
The NixOS wiki shows the configuration for x86: there the disko layout has an additional BIOS boot partition (EF02, 1 MB), and GRUB gets no EFI options. For Arm the wiki mentions virtio_gpu in boot.initrd.kernelModules and console=tty in boot.kernelParams in case the console stays black. It worked without for me.
configuration.nix
I start with a minimal configuration: SSH with keys only, network, time zone.
{
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@machine"
];
}You have to adjust four things here:
- The SSH key is the line from
~/.ssh/id_ed25519.pub. Without it you are locked out. - The IPv6 address is shown in the console next to the server. Hetzner gives you a /64 network, I use
::1. The gatewayfe80::1is always the same at Hetzner. The server gets IPv4 via DHCP, which is set inhardware.nix. - The network card of Hetzner's Arm servers is called
enp1s0. In the rescue system,ip ashows whether that holds for you too. system.stateVersionis the NixOS version of the first installation, not an update setting. I do not touch it again, not even when I move to a newer release.
I do not need to open port 22 for SSH separately, services.openssh takes care of that for me.
Then I create the flake.lock, which pins all versions, with nix flake lock and commit:
git init
git add .
nix flake lock
git add flake.lock
git commit -m "feat: initial NixOS config"3. Install
In the Hetzner Console I enable the rescue system (linux64) under Rescue, pick my SSH key and reboot the server under Power. The server then boots a small Linux in RAM and leaves the disk alone.
Then enter the following in your terminal:
nix run github:nix-community/nixos-anywhere -- \
--flake .#hetzner \
--target-host root@<ipv4> \
--build-on-remote--build-on-remote builds the system on the server instead of locally. On a machine without Arm it is the easiest way, otherwise it saves traffic.
This takes five to ten minutes, then the server reboots. More on the options in the nixos-anywhere quickstart guide.
A note on Arm: according to the README, nixos-anywhere expects x86-64 for the kexec boot by default and needs its own kexec image for other architectures. For me the command ran on the CAX without such an image.
4. Check
ssh root@<ipv4> "nixos-version"The first time, SSH warns that the host key has changed, because a different system is running there now. That can already happen with the rescue system. Remove the old entry with ssh-keygen -R <ipv4> and the connection works.
I check IPv6 separately, because a typo in the address would otherwise go unnoticed:
ssh -6 root@<ipv6> "nixos-version"5. Roll out changes
From now on I only change the server through the repo. I edit configuration.nix, for example, and send the new configuration to the server. For network changes I use test first instead of switch. It activates the configuration but does not make it the boot default, so if I lock myself out, a reboot from the console brings back the old state.
nix run nixpkgs#nixos-rebuild -- test \
--flake .#hetzner \
--target-host root@<ipv4> \
--build-host root@<ipv4>If everything works, I repeat the command with switch. That makes the configuration the default for the next boot. Every configuration is kept as its own generation. If something goes wrong, ssh root@<ipv4> nixos-rebuild switch --rollback brings back the previous one. And if the server is no longer reachable over SSH, you can pick an older generation in the boot menu via the Hetzner Console.
The nice part: if you need a second server, a single run of nixos-anywhere is enough.