FreeBSD on Hetzner Cloud 2: Cloud Init Boogaloo

Posted on Apr 22, 2026

So in my previous post I detailed the packer build to pull down the FreeBSD Cloud image and write it to /dev/sda from the Hetzner rescue OS. I tested nuageinit but, understandably it doesn’t support pulling metadata from IMDS.

However, cloud-init does, so I decided to tackle trying to shoe horn cloud-init into the FreeBSD image. Now there is probably a clever way to build a custom FreeBSD image with cloud-init pre-baked in but this was the way I thought it would be ’easy'.

The main gist here using packer is as follows:

  • Boot a instance into the Hetzner Rescue environment
  • wget then dd the FreeBSD cloud image to the instance’s disk
  • On the same rescue environment install ZFS support
  • Import the zroot pool we’ve just written to disk and plant some seeds
    • It took me an annoyingly long time to realise that packer creates a temporary SSH key at the start of the build process that I should be copying into the file system and not my key :sad-panda:
  • Reboot the instance from the rescue env > FreeBSD
  • Install py311-cloud-init and set it to start on boot
  • Copy in my SSH key to /root/.ssh/authorized_keys for debug incase cloud-init doesn’t work properly (spoiler, it didn’t)
  • Create a server snapshot - why isn’t it a HMI? ;)
  • Deploy a new instance using the snapshot

So all of the above works absolutely fine after many, many iterations to get the commands just right to import the zpool et al. The problem I have now is there’s a race condition on boot of cloud-init seems to start before networking is ready:

url_helper.py[DEBUG]: Exception(s) [UrlError('HTTPConnectionPool(host=\'169.254.169.254\', port=80): Max retries exceeded with url: /hetzner/v1/metadata/instance-id (Caused by NewConnectionError("HTTPConnection(host=\'169.254.169.254\', port=80): Failed to establish a new connection: [Errno 51] Network is unreachable"))')] during request to http://169.254.169.254/hetzner/v1/metadata/instance-id, raising last exception

I’ve tried enabling the netwait service to no avail.

After the new instance has booted running these commands kicks cloud-init to do its thing;

cloud-init clean --logs
cloud-init init --local
cloud-init init
cloud-init modules --mode=config
cloud-init modules --mode=final

My FreeBSD instance then gets it’s userdata applied including the hostname getting set, packages installed the lot.

Annoying but, I’m not giving up just yet.

Updated Packer Build

packer {
  required_plugins {
    hcloud = {
      version = "~> 1"
      source  = "github.com/hetznercloud/hcloud"
    }
  }
}

variable "freebsd_version" {
  type    = string
  default = "15.0-RELEASE"
}

variable "image_url_arm" {
  type    = string
  default = null
}

variable "image_url_x86" {
  type    = string
  default = null
}

variable "server_location" {
  type    = string
  default = "hel1"
}

locals {
  timestamp     = formatdate("YYYY-MM-DD_hhmm", timestamp())
  image_url_arm = var.image_url_arm != null ? var.image_url_arm : "https://download.freebsd.org/releases/VM-IMAGES/${var.freebsd_version}/aarch64/Latest/FreeBSD-${var.freebsd_version}-arm64-aarch64-BASIC-CLOUDINIT-zfs.raw.xz"
  image_url_x86 = var.image_url_x86 != null ? var.image_url_x86 : "https://download.freebsd.org/releases/VM-IMAGES/${var.freebsd_version}/amd64/Latest/FreeBSD-${var.freebsd_version}-amd64-BASIC-CLOUDINIT-zfs.raw.xz"

  download_image = <<-EOT
    set -ex
    echo 'Downloading FreeBSD Image...'
    wget --timeout=5 --waitretry=5 --tries=5 --retry-connrefused --inet4-only -O /tmp/freebsd.raw.xz "$FREEBSD_IMAGE_URL"
  EOT

  write_image = <<-EOT
    set -ex
    # Verify file exists before attempting to decompress
    if [ ! -f /tmp/freebsd.raw.xz ]; then
      echo "ERROR: /tmp/freebsd.raw.xz not found!"
      exit 1
    fi
    echo 'FreeBSD image loaded, writing to disk... '
    xz -d -c /tmp/freebsd.raw.xz | dd of=/dev/sda && sync
    echo 'done.'
  EOT

  install_zfs_on_rescue_system = <<-EOT
    yes | bash /root/.oldroot/nfs/tools/install_openzfs.sh
    modprobe zfs
  EOT

  prepare_freebsd_for_configuration = <<-EOT
    # Mount zroot ZFS pool to drop ssh keys and sshd config
    zpool import -N -R /mnt zroot
    zfs set mountpoint=/zroot zroot/ROOT/default
    zfs mount zroot/ROOT/default
    mkdir /mnt/zroot/root/.ssh
    # Copy Packer's key for reconnection after reboots
    if [ -f /root/.ssh/authorized_keys ]; then
      cat /root/.ssh/authorized_keys >> /mnt/zroot/root/.ssh/authorized_keys
    fi
    chmod 700 /mnt/zroot/root/.ssh
    chmod 600 /mnt/zroot/root/.ssh/authorized_keys
    # Configure sshd
    sed -i 's/^#PermitRootLogin no/PermitRootLogin prohibit-password/' /mnt/zroot/etc/ssh/sshd_config
    echo 'Exporting zroot and rebooting...'
    zpool export zroot
    reboot
  EOT

  install_packages = <<-EOT
    pkg bootstrap -f
    # cloud-init
    pkg install -y py311-cloud-init curl
    sysrc cloudinit_enable=YES
    # netwait
    sysrc netwait_enable=YES
    sysrc netwait_ip="1.1.1.1"
  EOT

  ssh_key_cleanup = <<-EOT
    # Overwrite the packer temporary ssh key with mine from GitHub
    curl -4 https://github.com/grantbevis.keys > /root/.ssh/authorized_keys
  EOT

}

source "hcloud" "freebsd-arm" {

  rescue       = "linux64"
  image        = "debian-13"
  location     = var.server_location
  server_type  = "cax31"
  ssh_username = "root"

  snapshot_name = "FreeBSD ${var.freebsd_version} ARM - ${local.timestamp}"
  snapshot_labels = {
    os      = "freebsd",
    version = var.freebsd_version,
    arch    = "arm",
  }
}

source "hcloud" "freebsd-x86" {

  rescue       = "linux64"
  image        = "debian-13"
  location     = var.server_location
  server_type  = "cx22"
  ssh_username = "root"

  snapshot_name = "FreeBSD ${var.freebsd_version} x86 - ${local.timestamp}"
  snapshot_labels = {
    os      = "freebsd",
    version = var.freebsd_version,
    arch    = "x86",
  }
}

build {
  sources = [
    "source.hcloud.freebsd-arm",
    "source.hcloud.freebsd-x86",
  ]

  provisioner "shell" {
    inline = [local.download_image]
    environment_vars = [
      "FREEBSD_IMAGE_URL=${local.image_url_arm}",
    ]
    only = ["hcloud.freebsd-arm"]
  }

  provisioner "shell" {
    inline = [local.download_image]
    environment_vars = [
      "FREEBSD_IMAGE_URL=${local.image_url_x86}",
    ]
    only = ["hcloud.freebsd-x86"]
  }

  provisioner "shell" {
    inline = [local.write_image]
  }

  provisioner "shell" {
    inline = [local.install_zfs_on_rescue_system]
  }

  provisioner "shell" {
    inline            = [local.prepare_freebsd_for_configuration]
    expect_disconnect = true
  }

  provisioner "shell" {
    environment_vars = [
      "ASSUME_ALWAYS_YES=yes"
    ]
    inline = [local.install_packages]
  }

  provisioner "shell" {
    inline = [local.ssh_key_cleanup]
  }
}