Skip to content

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

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.

nixos-hetzner.pkr.hcl
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.

nixos-hetzner.pkr.hcl
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.

nixos-hetzner.pkr.hcl
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.

$ ls -1
nixos-hetzner.pkr.hcl
setup-nixos.sh
$ packer init .
# no output

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.

nixos-hetzner.pkr.hcl
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.

setup-nixos.sh
#!/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.

setup-nixos.sh
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.

setup-nixos.sh
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.

setup-nixos.sh
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
setup-nixos.sh
mkdir /mnt/nix
unsquashfs -d /mnt/nix/store nixos/nix-store.squashfs

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!?

setup-nixos.sh
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.

setup-nixos.sh
nixos-generate-config --root /mnt

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.

setup-nixos.sh
{ 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.

setup-nixos.sh
nixos-install --no-root-passwd --root /mnt

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.

setup-nixos.sh
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/*
nixos-hetzner.pkr.hcl
  provisioner "shell" {
    inline = [
      "rm -f /tmp/install.sh",
      "sync",
    ]
  }

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.

main.tf
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

Comments