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
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.
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.
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.
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 usinglatest. 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.