Packer: How to Build NixOS 24 Snapshot on Hetzner Cloud¶
Packer is a powerful tool to create immutable images, with support for various cloud providers.
In this blog post, I share how I built a NixOS 24 snapshot using Packer on Hetzner Cloud.
If you're a fan of NixOS or want to learn more about Packer, this post is for you.
Introduction¶
I have been on the sidelines for a long time, waiting for an opportunity to dip my toes into the NixOS ecosystem.
I have been hearing a lot of good things about it and I wanted to see what the fuss was all about.
Additionally, I have been using Hetzner Cloud for a while now, both personally and professionally in production setups.
I thought it would be a good idea to combine the two and see what I can come up with.
Prerequisites¶
- Packer v1.11 installed on your local machine1
- Hetzner Cloud account with an API token
- Use my referral code to get €20 in credits : https://hetzner.cloud/?ref=ai5E5vaX1J71
Getting Started¶
There are various ways of installing NixOS, and I found it somehow confusing that the docs are scattered all over the internet, not making it easy for beginners to get started.
High barrier to entry is perhaps one of the reasons why NixOS is not as wide-spread as other Linux distributions!
Packer Configuration¶
Let us jump straight into it, cause there ain't much pre-work needed.
If you know, you know, and if you don't, I'll explain the file as much as possible in a bit.
packer {
required_plugins {
hcloud = {
version = "< 2.0.0"
source = "github.com/hetznercloud/hcloud"
}
}
}
variable "hcloud_token" {
type = string
sensitive = true
default = env("HCLOUD_TOKEN")
}
locals {
timestamp = regex_replace(timestamp(), "[- TZ:]", "")
ssh_public_key = file(pathexpand("~/.ssh/hetzner-nixos.pub"))
nixos_version = "24.11"
nixpkgs_version = "branch-off-24.11"
}
source "hcloud" "nixos" {
image = "debian-12"
rescue = "linux64"
location = "nbg1"
server_type = "cax31" # ARM64, 8 vCPUs, 16 GB RAM, €12/month
snapshot_name = "nixos-${local.timestamp}"
snapshot_labels = {
os = "nixos"
nixos = local.nixos_version
nixos_major = regex_replace(local.nixos_version, "\\..*", "")
timestamp = local.timestamp
}
ssh_username = "root"
token = "${var.hcloud_token}"
}
build {
sources = ["source.hcloud.nixos"]
provisioner "file" {
content = templatefile("${path.root}/setup-nixos.sh", {
nixos_version = local.nixos_version
nixpkgs_version = local.nixpkgs_version
ssh_public_key = local.ssh_public_key
})
destination = "/tmp/install.sh"
direction = "upload"
}
provisioner "shell" {
inline = [
"chmod +x /tmp/install.sh",
"/tmp/install.sh",
]
}
provisioner "shell" {
inline = [
"rm -f /tmp/install.sh",
"sync",
]
}
}
Not a lot of excitement is happening here and the main part of the deal is that setup-nixos.sh
file in the same working directory.
However, before we go over the shell script, let's briefly explain Packer concepts for those unfamiliar with it.
Packer Concepts¶
There are, generally speaking, two main components to each Packer configuration file, whether you write it in the HCL language or the JSON format2.
The first component is the source
block, specifying which cloud provider you are targeting and where will you store your final snapshot image3.
source "hcloud" "nixos" {
image = "debian-12"
rescue = "linux64"
location = "nbg1"
server_type = "cax31" # ARM64, 8 vCPUs, 16 GB RAM, €12/month
snapshot_name = "nixos-${local.timestamp}"
snapshot_labels = {
os = "nixos"
nixos = local.nixos_version
nixos_major = regex_replace(local.nixos_version, "\\..*", "")
timestamp = local.timestamp
}
ssh_username = "root"
token = "${var.hcloud_token}"
}
For every cloud provider, you will need to include the corresponding plugin block.
packer {
required_plugins {
hcloud = {
version = "< 2.0.0"
source = "github.com/hetznercloud/hcloud"
}
}
}
Now, funny enough, the github.com/hetznercloud/hcloud
will not be an accessible internet URL. Unlike, for example, Go packages, these are arbitrary names assigned to the plugin.
Once you define your plugins, you got to import its files using packer init
. This command needs to run in the same directory as your *.pkr.hcl
file.
Packer Build¶
The second building block of Packer configuration file is the build
block.
Regardless of which source
you are using, you can define your build
block agnostic of the cloud provider, having custom and arbitrary shell scripts, Ansible playbooks, etc.
This is where we define and customize our images, updating and upgrading packages, installing new stuff, etc.
build {
sources = ["source.hcloud.nixos"]
provisioner "file" {
content = templatefile("${path.root}/setup-nixos.sh", {
nixos_version = local.nixos_version
nixpkgs_version = local.nixpkgs_version
ssh_public_key = local.ssh_public_key
})
destination = "/tmp/install.sh"
direction = "upload"
}
provisioner "shell" {
inline = [
"chmod +x /tmp/install.sh",
"/tmp/install.sh",
]
}
provisioner "shell" {
inline = [
"rm -f /tmp/install.sh",
"sync",
]
}
}
Installing NixOS in Rescue Mode¶
Since we want to overwrite the current OS with the new NixOS, we'll need to take over the boot of the system and break into the Hetzner server rescue mode4.
That gives us the opportunity to write any OS to the current /dev/sda
and once restarted, boot into that new operating system.
That's exactly what this next step is all about.
Let's provide the config file first and go through the steps together.
#!/bin/bash
set -eux
export DEBIAN_FRONTEND=noninteractive
apt update
apt install -y squashfs-tools
# ref: https://gist.github.com/chris-martin/4ead9b0acbd2e3ce084576ee06961000
wget https://channels.nixos.org/nixos-${nixos_version}/latest-nixos-minimal-$(uname -m)-linux.iso -O nixos.iso
mkdir nixos
mount -o loop nixos.iso nixos
mkdir /nix
unsquashfs -d /nix/store nixos/nix-store.squashfs
NIXOS_INSTALL=$(find /nix/store -path '*-nixos-install/bin/nixos-install')
NIX_INSTANTIATE=$(find /nix/store -path '*-nix-*/bin/nix-instantiate')
NIXOS_GENERATE_CONFIG=$(find /nix/store -path '*-nixos-generate-config/bin/nixos-generate-config')
export PATH="$(dirname $NIXOS_INSTALL):$(dirname $NIX_INSTANTIATE):$(dirname $NIXOS_GENERATE_CONFIG):$PATH"
groupadd --system nixbld
useradd --system --home-dir /var/empty --shell $(which nologin) -g nixbld -G nixbld nixbld0
wget https://github.com/NixOS/nixpkgs/archive/refs/tags/${nixpkgs_version}.zip -O nixpkgs.zip
unzip nixpkgs.zip
mv nixpkgs-* nixpkgs
export NIX_PATH=nixpkgs=$HOME/nixpkgs
parted -s /dev/sda -- mklabel gpt
parted -s /dev/sda -- mkpart root xfs 512MB -2GB
parted -s /dev/sda -- mkpart swap linux-swap -2GB 100%
parted -s /dev/sda -- mkpart ESP fat32 1MB 512MB
parted -s /dev/sda -- set 3 esp on
mkfs.ext4 -L nixos /dev/sda1
mkswap -L swap /dev/sda2
mkfs.fat -F 32 -n boot /dev/sda3
mount /dev/disk/by-label/nixos /mnt
mkdir -p /mnt/boot
mount -o umask=077 /dev/disk/by-label/boot /mnt/boot
swapon /dev/sda2
mkdir /mnt/nix
unsquashfs -d /mnt/nix/store nixos/nix-store.squashfs
nixos-generate-config --root /mnt
cat > /mnt/etc/nixos/configuration.nix <<EOL
{ config, pkgs, ... }:
{
imports = [ ./hardware-configuration.nix ];
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
environment.systemPackages = with pkgs; [
vim
git
wget
curl
tmux
openssh
pkgs.util-linux
];
networking.hostName = "nixos";
networking.networkmanager.enable = true;
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "prohibit-password";
users.users.root.openssh.authorizedKeys.keys = [
"${ssh_public_key}"
];
system.stateVersion = "${nixos_version}";
}
EOL
nixos-install --no-root-passwd --root /mnt
rm -rf /mnt/root/.nix-profile
rm -rf /mnt/root/.nix-defexpr
rm -rf /mnt/root/.nix-channels
rm -rf /tmp/*
nix-collect-garbage -d
rm -rf /var/log/*
Take a good look at the script and worry not if you don't understand it fully, cause neither do I.
We are downloading the NixOS from the official channel, pinning it to exact version as well as taking into account the current CPU architecture.
wget https://channels.nixos.org/nixos-${nixos_version}/latest-nixos-minimal-$(uname -m)-linux.iso -O nixos.iso
We then create the required user and group that need to be present before NixOS installation.
groupadd --system nixbld
useradd --system --home-dir /var/empty --shell $(which nologin) -g nixbld -G nixbld nixbld0
We are also downloading the Nix packages and store them locally to be referenced later on by the installer.
wget https://github.com/NixOS/nixpkgs/archive/refs/tags/${nixpkgs_version}.zip -O nixpkgs.zip
unzip nixpkgs.zip
mv nixpkgs-* nixpkgs
export NIX_PATH=nixpkgs=$HOME/nixpkgs
Notice the need for setting the proper environment variables. These env vars are needed by the installer at the final step.
Disk Partitioning¶
Since we are about to create a new OS on the target machine, we are bound to do some partitioning.
Now, I know some might not like this tedious task, but let's just get over it, shall we!?
parted -s /dev/sda -- mklabel gpt
parted -s /dev/sda -- mkpart root xfs 512MB -2GB
parted -s /dev/sda -- mkpart swap linux-swap -2GB 100%
parted -s /dev/sda -- mkpart ESP fat32 1MB 512MB
parted -s /dev/sda -- set 3 esp on
mkfs.ext4 -L nixos /dev/sda1
mkswap -L swap /dev/sda2
mkfs.fat -F 32 -n boot /dev/sda3
mount /dev/disk/by-label/nixos /mnt
mkdir -p /mnt/boot
mount -o umask=077 /dev/disk/by-label/boot /mnt/boot
swapon /dev/sda2
The best part iof it all is that the nixos-generate-config
will know about these partitions and will create the corresponding /etc/nixos/hardware-configuration.nix
to be used by the installer.
NixOS Configuration¶
One last step before we start the installation is to ensure a basic configuration exists. Now, I'm no NixOS expert and this config is by far not the best one, but it's a start.
{ config, pkgs, ... }:
{
imports = [ ./hardware-configuration.nix ];
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
environment.systemPackages = with pkgs; [
vim
git
wget
curl
tmux
openssh
pkgs.util-linux
];
networking.hostName = "nixos";
networking.networkmanager.enable = true;
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "prohibit-password";
users.users.root.openssh.authorizedKeys.keys = [
"${ssh_public_key}"
];
system.stateVersion = "${nixos_version}";
}
Having this config in place, there's only one step left and that's the actual NixOS installation.
This command will take somewhere north of 6 minutes to complete.
At the end, the server will be removed and you'll no longer be billed for it.
Additionally, the newly created snapshot will be available and billed correspondingly. So, if you don't want to use it, make sure to remove it!
Cleanups¶
Because we should be aiming for a minimal image footprint, we have to clean up the mess we've made so far, making the final image lighter and more efficient.
rm -rf /mnt/root/.nix-profile
rm -rf /mnt/root/.nix-defexpr
rm -rf /mnt/root/.nix-channels
rm -rf /tmp/*
nix-collect-garbage -d
rm -rf /var/log/*
Caveats¶
This is all very good and educational, maybe even to a certain extent entertaining.
But, is this the best way!? I can't be sure.
At the very least, I realized that without increasing the size of the server to at least cax31
would not give me enough disk storage in the rescue mode to download and unsquash the Nix packages.
One might find a better way to do this without such compromise.
Especially knowing that the server type that you start with and create your images on is the minimal spec of any future image you will be able to create from that snapshot.
Bonus¶
As a last token of appreciation to those of you who stuck around till this far, I am providing the sample OpenTofu code that will be used to create Hetzner servers from the created snapshot.
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "< 2"
}
}
}
data "hcloud_image" "nixos_image" {
with_selector = "nixos_major=24"
most_recent = true
}
resource "hcloud_server" "this" {
name = "nixos-server"
image = data.hcloud_image.nixos_image.id
server_type = "cax31"
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
}
To apply this, we do business as usual:
export HCLOUD_TOKEN="<your-hetzner-api-token>"
tofu init -upgrade
tofu plan -out tfplan
tofu apply tfplan
Conclusion¶
We've seen how to create a Hetzner snapshot of one of the well-known Linux distributions, NixOS, using Packer.
I'm just starting out and I gotta take these baby steps before being able to run.
As for you, if you've learned and enjoyed this piece, that's fanstastic. And if you know how to do this process better than me, please leave a comment down below so that I can learn from you.
Until next time, ciao & happy coding!
If you enjoyed this blog post, consider sharing it with these buttons . Please leave a comment for us at the end, we read & love 'em all.
Share on Share on Share on Share on