| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

kldload → Packer → Terraform — the full pipeline

The standard path from bare metal to cloud-deployable image has always been tedious: install the OS, set up ZFS manually, configure your tooling, snapshot, export, repeat for every target platform. kldload eliminates that. The Core profile installs a clean ZFS-on-root base in minutes. kexport turns it into a qcow2. Packer bakes your application in. Terraform deploys it everywhere.

Why Core? The Core profile is exactly what a Packer base image should be: ZFS on root, WireGuard kernel module, SSH enabled, nothing else. No GNOME, no k* tools, no opinions. Every byte you don't put in the base image is a byte Packer doesn't have to deal with. Core gives you a clean starting point that is actually smaller than a typical cloud distro image — but with a proper ZFS pool instead of an ext4 mess.

The payoff: Every Packer build starts from a working ZFS system. Your provisioners run inside a live pool. Snapshots work during provisioning. The output image is ZFS-native from byte zero. You never write another 200-line Kickstart or preseed to set up ZFS in Packer again.


Pipeline overview

1

Install kldload Core on a VM

Boot the kldload ISO in KVM or Proxmox. Select Core profile. Pick your distro (CentOS for RPM-based images, Debian for APT-based). The installer sets up ZFS on root, SSH, and nothing else. Takes about 5 minutes.

2

Export with kexport

Shut down the VM. Use kexport to produce a qcow2 (or raw, vmdk, vhd). The exported image is your Packer source. Store it in your artifact repo — it is your reusable base layer.

3

Packer HCL template

Point Packer's QEMU builder at the qcow2. Boot it, SSH in, run your provisioner scripts. Packer produces a new qcow2 with your application baked in.

4

Terraform deploy

Reference the Packer output image in your Terraform code. Deploy to Proxmox, AWS (via AMI import), Azure, or any QEMU-compatible hypervisor. Every instance starts from your tested, ZFS-backed image.


Step 1 — Install kldload Core

# Boot the kldload ISO in a KVM VM (using virt-install)
virt-install \
  --name kldload-base \
  --ram 4096 \
  --vcpus 4 \
  --disk size=20,format=qcow2,bus=virtio \
  --cdrom /path/to/kldload-latest.iso \
  --os-variant centos-stream9 \
  --graphics vnc \
  --boot uefi

# In the web UI (http://localhost:8080):
# 1. Select disk: /dev/vda
# 2. Profile: Core
# 3. Distro: CentOS Stream 9 (or Debian Trixie)
# 4. Passphrase: (leave empty for unattended boot, or set one)
# 5. Install

# After install completes, shut down cleanly
virsh shutdown kldload-base

Step 2 — Export with kexport

# kexport runs inside the live system before shutdown, or from a separate host
# pointing at the disk image

# Option A: run kexport from inside the installed system (before shutdown)
ssh root@<vm-ip>
kexport /dev/vda qcow2
# Output: /tmp/kldload-export-YYYYMMDD-HHMMSS.qcow2

# Option B: export the raw qcow2 from the hypervisor host directly
virsh domblkinfo kldload-base vda
# Then compress and deduplicate with qemu-img
qemu-img convert -O qcow2 -c \
  ~/.local/share/libvirt/images/kldload-base.qcow2 \
  /opt/base-images/kldload-core-centos-20260325.qcow2

# Check image size
qemu-img info /opt/base-images/kldload-core-centos-20260325.qcow2
# image: kldload-core-centos-20260325.qcow2
# file format: qcow2
# virtual size: 20 GiB (21474836480 bytes)
# disk size: 1.4 GiB   ← compressed, actual data only

Step 3 — Packer HCL template

The QEMU builder boots the qcow2 image, waits for SSH, runs your provisioners, and writes a new image. This is the standard Packer flow — the only difference is that the source image is your kldload Core export.

# packer/kldload-base.pkr.hcl

packer {
  required_plugins {
    qemu = {
      version = ">= 1.0.9"
      source  = "github.com/hashicorp/qemu"
    }
  }
}

variable "base_image" {
  default = "/opt/base-images/kldload-core-centos-20260325.qcow2"
}

variable "output_name" {
  default = "myapp-server"
}

source "qemu" "kldload_core" {
  iso_url          = var.base_image
  iso_checksum     = "none"           # use sha256 in production
  disk_image       = true             # source is already a disk image
  format           = "qcow2"
  disk_size        = "20G"
  memory           = 4096
  cpus             = 4
  accelerator      = "kvm"

  ssh_username     = "root"
  ssh_password     = "kldload"        # set during kldload install
  ssh_timeout      = "10m"

  boot_wait        = "5s"
  shutdown_command = "systemctl poweroff"

  output_directory = "output/${var.output_name}"
  vm_name          = "${var.output_name}.qcow2"
}

build {
  sources = ["source.qemu.kldload_core"]

  # Update packages first
  provisioner "shell" {
    inline = [
      "dnf update -y",
      "dnf clean all"
    ]
  }

  # Install your application
  provisioner "shell" {
    script = "scripts/install-myapp.sh"
  }

  # Drop in configuration files
  provisioner "file" {
    source      = "files/myapp.conf"
    destination = "/etc/myapp/myapp.conf"
  }

  # Enable services
  provisioner "shell" {
    inline = [
      "systemctl enable myapp",
      "systemctl enable --now node_exporter",
      # Take a ZFS snapshot of the baked state — this is your golden snapshot
      "zfs snapshot rpool/ROOT/default@packer-baked"
    ]
  }

  # Post-processor: compress and rename
  post-processor "compress" {
    output = "output/${var.output_name}/${var.output_name}.qcow2.gz"
  }
}
# Run the Packer build
packer init packer/kldload-base.pkr.hcl
packer build packer/kldload-base.pkr.hcl

# Output: output/myapp-server/myapp-server.qcow2
# This image has your app installed on a live ZFS pool.

Step 4a — Terraform deploy to Proxmox

The Proxmox Terraform provider can upload a qcow2, create a VM template from it, and clone as many instances as you need.

# terraform/proxmox/main.tf

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.46"
    }
  }
}

provider "proxmox" {
  endpoint  = "https://proxmox.example.com:8006"
  api_token = var.proxmox_token
  insecure  = false
}

# Upload the Packer image as a VM template
resource "proxmox_virtual_environment_download_file" "myapp_image" {
  node_name    = "pve"
  content_type = "iso"
  datastore_id = "local"
  file_name    = "myapp-server.qcow2"
  url          = "http://artifact-store.internal/myapp-server.qcow2"
}

# Deploy N instances from the image
resource "proxmox_virtual_environment_vm" "myapp" {
  count     = var.instance_count
  name      = "myapp-${count.index + 1}"
  node_name = "pve"

  cpu {
    type  = "host"
    cores = 4
  }

  memory {
    dedicated = 8192
  }

  disk {
    datastore_id = "local-zfs"
    file_id      = proxmox_virtual_environment_download_file.myapp_image.id
    interface    = "virtio0"
    size         = 20
  }

  network_device {
    bridge = "vmbr0"
    model  = "virtio"
  }

  agent {
    enabled = true
  }
}

output "vm_ids" {
  value = proxmox_virtual_environment_vm.myapp[*].vm_id
}
terraform init
terraform plan
terraform apply -var="instance_count=3"

Step 4b — Terraform deploy to AWS

AWS requires importing qcow2 as an AMI first. Use the AWS VM Import/Export service, then reference the AMI in Terraform.

# Convert qcow2 to raw for AWS import
qemu-img convert -O raw output/myapp-server/myapp-server.qcow2 myapp-server.raw

# Upload raw image to S3
aws s3 cp myapp-server.raw s3://my-ami-bucket/myapp-server.raw

# Import as a snapshot
aws ec2 import-snapshot \
  --description "kldload myapp-server" \
  --disk-container "Format=RAW,UserBucket={S3Bucket=my-ami-bucket,S3Key=myapp-server.raw}"

# Note the snapshot ID from the response, then register as AMI
aws ec2 register-image \
  --name "myapp-server-20260325" \
  --root-device-name /dev/xvda \
  --block-device-mappings "[{\"DeviceName\":\"/dev/xvda\",\"Ebs\":{\"SnapshotId\":\"snap-XXXXXXXX\"}}]" \
  --virtualization-type hvm \
  --boot-mode uefi
# terraform/aws/main.tf

data "aws_ami" "myapp" {
  most_recent = true
  owners      = ["self"]

  filter {
    name   = "name"
    values = ["myapp-server-*"]
  }
}

resource "aws_instance" "myapp" {
  count         = var.instance_count
  ami           = data.aws_ami.myapp.id
  instance_type = "t3.medium"

  tags = {
    Name = "myapp-${count.index + 1}"
  }
}

Why this pipeline works

ZFS from byte zero

Every instance that Terraform deploys boots a real ZFS pool — not an ext4 root with ZFS bolted on. Snapshots, datasets, send/recv all work from first boot. No post-boot ZFS setup scripts.

Immutable base layer

The kldload Core qcow2 never changes. Packer builds layer on top of it. If your provisioners break something, you rebuild Packer from the same clean base. The base is stable and auditable.

Baked ZFS snapshots

The Packer provisioner can zfs snapshot after each major step. Every deployed instance starts with those snapshots in place — an instant rollback point if something goes wrong post-deploy.

No opinions in Core

Core installs ZFS on root and SSH. Nothing else. No package manager wrappers, no web UI, no monitoring agents. Your provisioners add exactly what you need. The base is 100% predictable.

Multi-platform from one source

One kldload Core qcow2 can feed multiple Packer builds: one for Proxmox, one for AWS AMI, one for Azure VHD. Same base, different post-processors. Parity across clouds.

Offline builds

kldload's darksite means Packer provisioners can install RPM/DEB packages without internet access. Build in an air-gapped CI environment. All packages are baked into the ISO and accessible via local repo.


CI integration

The full pipeline runs in CI with a single trigger. A typical GitHub Actions or Gitea Actions workflow:

# .github/workflows/build-image.yml
name: Build and deploy kldload image

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  build:
    runs-on: self-hosted   # needs KVM access
    steps:
      - uses: actions/checkout@v4

      - name: Build Packer image
        run: |
          packer init packer/
          packer build -var="output_name=myapp-${{ github.sha }}" packer/

      - name: Upload to artifact store
        run: |
          aws s3 cp output/myapp-${{ github.sha }}/myapp-*.qcow2 \
            s3://my-artifacts/images/

  deploy:
    needs: build
    runs-on: self-hosted
    steps:
      - name: Terraform apply
        run: |
          terraform -chdir=terraform/proxmox apply \
            -var="image_sha=${{ github.sha }}" \
            -auto-approve

Tip: Pin the kldload Core base image version in your Packer variable file (base_image = "kldload-core-centos-1.0.qcow2") rather than always using latest. When you want to update the base, bump the version intentionally, test the full pipeline, then promote. Treat base image upgrades the same way you treat OS upgrades — deliberate, tested, rollback-ready.