Deploying NixOS to Hetzner EX44 dedicated server
I like to use NixOS on my servers, it makes dealing with upgrades easy and provides a good deal of stability. The declarative configuration is a game changer - I can rebuild my server setup anywhere with minimal hassle.
I’ve recently purchased a Hetzner EX44 dedicated server, and despite a good number of blog or wiki articles about how to deploy NixOS on baremetal at Hetzner, I did not find one that worked out of the box for this specific model. So here is a quick recap of how I’ve managed to configure the server.
Pre-deployment Planning
Before thinking of deploying NixOS, the first step is to understand what network and disk configuration will work for your server.
Networking Configuration
Hetzner requires configuring static IP addresses, and its Robot dashboard provides everything that’s needed to do that.
IPv4 Configuration
- IP: the server IP address
- Gateway: the gateway address
- Mask: the server subnet mask
- Prefix: compute this from the subnet mask, ex: 255.255.255.224 -> /27
IPv6 Configuration
- IP: the server IPv6 address
- Gateway: the IPv6 gateway address
- Prefix: usually /64
DNS
Do not forget to add DNS servers to the network config, this is quite useful if you need to debug the installation using the virtual KVM.
Firewall
It is good practice to configure the firewall to allow only the necessary ports. This is pretty standard but there is one gotcha: you will need to allow incoming ACK TCP packets so that your server can establish outside TCP connections.
Disk Setup
The bootloader setup that is supported on Hetzner servers is documented at Hetzner EFI System Partition. Basically, this boils down to creating an ESP partition that is at least 200MB large.
Installing NixOS with nixos-anywhere
In the past, I’ve personally installed NixOS on various computers using a multitude of methods. The last time I set up a dedicated server with NixOS was manually using kexec to boot a basic NixOS image and install NixOS from there.
With this new server, I’ve searched for new methods and found that nixos-anywhere was the one I liked the most. First, it was easily reproducible; second, it integrates with disko, which handles disk partitioning; third, it actually works.
Here’s a sample configuration I used with nixos-anywhere:
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixos-anywhere = {
url = "github:nix-community/nixos-anywhere";
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nixos-anywhere, disko, ... }: {
nixosConfigurations.ex44 = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
disko.nixosModules.disko
./disk-config.nix
./network-config.nix
];
};
};
}
For disk configuration, I created a simple disk-config.nix file setting up a raid1 partition for /boot and creating a mirror zfs pool for the rest of the disk:
# disk-config.nix
{ ... }:
let
mirrorBoot = idx: {
type = "disk";
device = "/dev/nvme${idx}n1";
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "mdraid";
name = "boot";
};
};
zfs = {
size = "100%";
content = {
type = "zfs";
pool = "zroot";
};
};
};
};
};
in {
disko.devices = {
disk = {
nvme0n1 = mirrorBoot "0";
nvme1n1 = mirrorBoot "1";
};
mdadm = {
boot = {
type = "mdadm";
level = 1;
metadata = "1.0";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
};
zpool = {
zroot = {
type = "zpool";
mode = "mirror";
rootFsOptions = {
compression = "lz4";
acltype = "posixacl";
xattr = "sa";
"com.sun:auto-snapshot" = "true";
mountpoint = "none";
};
datasets = {
"root" = {
type = "zfs_fs";
options = { mountpoint = "none"; };
};
"root/nixos" = {
type = "zfs_fs";
options.mountpoint = "/";
mountpoint = "/";
};
"root/home" = {
type = "zfs_fs";
options.mountpoint = "/home";
mountpoint = "/home";
};
"root/tmp" = {
type = "zfs_fs";
mountpoint = "/tmp";
options = {
mountpoint = "/tmp";
sync = "disabled";
};
};
};
};
};
};
}
The network configuration is also fairly straightforward:
# network-config.nix
{ ... }: {
networking = {
hostId = "a 32bit host id for zfs";
hostName = "ex44";
useDHCP = false;
# Disable predictable interface names because the server only has one network card and, ironically, it makes the configuration a bit more predictable.
usePredictableInterfaceNames = false;
interfaces."eth0" = {
ipv4.addresses = [{
address = "your.server.ip";
prefixLength = 27; # computed from the mask
}];
ipv6.addresses = [{
address = "your:server:ipv6::address";
prefixLength = 64;
}];
};
defaultGateway = "your.gateway.ip";
};
}
To deploy using nixos-anywhere from the rescue system, I simply ran:
nix run github:nix-community/nixos-anywhere -- --flake .#ex44 --target-host [email protected] --generate-hardware-config nixos-generate-config hardware-configuration.nix --disko-mode disko
In the end, following the nixos-anywhere quickstart tutorial with a configuration that matches the requirements we gathered earlier worked perfectly.
Post-Installation Management
Once the server is up and running with NixOS, you can fall back to your preferred way of deploying it. As for myself, I use comin which allows me to have a GitOps workflow for managing my servers. In a later article, I’ll explain how to automatically upgrade the flake inputs and also have a nice workflow to show the derivation diffs on PRs to the flake repository.
If you run into issues with your deployment, the Hetzner KVM console can be a lifesaver - it gives you direct console access even when networking isn’t working yet.
Acknowledgments
I’ve mentioned reading a few articles on deploying Nixos to Hetzner servers and I’d like to list them here because they were super useful to me: