Guide: Building a Kairos Hadron VM Template¶
This guide builds a custom Kairos Hadron
image — Kairos's musl/immutable, package-manager-less base — into a
bootable ISO and, from there, a vSphere VM template. It covers the step
before the one Using banlieue-imagebuilder
starts from: that guide takes an already-built OCI image and turns it into a
VMImage build artifact; this guide is how you produce that OCI image in the
first place when the stock kairos-io/hadron release needs extra payloads
(an EDR/security agent, org-specific hardening, static tooling, orchestrator
directories) baked in.
Two independent ways to consume the result:
- Manual / govc, as shown at the end of this guide — useful for a first build, air-gapped environments, or debugging the pipeline itself.
- banlieue-native — push the OCI image, point a
VMImagewithsources: [{kind: Url, ...}]at it, and letbanlieue-imagebuilder+ the vSphere provider do the ISO build, upload, VM creation, install-wait, and templating per the ADR-0021 contract. The cloud-config contract in that ADR (anadmin-groupusersentry,install.poweroff: true, identity wipe inafter-install-chroot) is a strict subset of what this guide's cloud-config already does.
Why Hadron needs a custom image at all¶
Kairos ships several base families (core on Ubuntu/openSUSE, alpine,
and hadron — a from-scratch musl build). Hadron trades the convenience of
apk/apt/dnf for a much smaller, more auditable image: it ships with
no package manager whatsoever. That single fact drives most of this
Dockerfile's shape:
- Anything that would normally be
apk add/dnf install-ed at build time has to be assembled elsewhere and copied in as plain files. - Any glibc-linked third-party binary (most commercial EDR/security agents,
for example) needs
gcompat(a musl→glibc compatibility shim) and its own runtime libraries copied alongside it — Hadron's own musl libc must never be overwritten, since that risks an ABI mismatch for the whole system. - VMware guest integration (
vmtoolsd, used below to readguestinfoat boot) has to be extracted from a package that does ship it (Alpine, in this guide) rather than installed normally.
Prerequisites¶
- Docker (or another OCI builder) with BuildKit.
- Network reachability to
quay.io/kairos-io(or an internal mirror of it) and to the base images your organization pins (Alpine, in thepayloadsstage below). auroraboot(run viadocker run, no separate install needed) to turn the built OCI image into a bootable ISO.- If targeting vSphere:
govcand a datacenter/cluster/datastore to upload to — see the Alpine template guide'sgovcsetup section, which applies unchanged here.
The Dockerfile¶
Multi-stage: one stage assembles anything that needs a package manager
(payloads), one stage grabs a statically-linked curl from Hadron's own
toolchain image, and the final stage starts FROM kairos-io/hadron and never
touches a package manager again.
# ---- versions ----
ARG KAIROS_INIT_VERSION=v0.17.3
ARG HADRON_VERSION=v0.5.1
# Keep in sync with any sibling Alpine-based build — the payloads stage
# below relies on this release's apk package names/layouts.
ARG ALPINE_VERSION=3.21
ARG VERSION=0.1.0
ARG MODEL=generic
ARG TRUSTED_BOOT=false
# Pin your own agent's version here if you bake one in (see the payloads
# stage below) — resolve it at build time (CI queries your artifact registry)
# rather than hardcoding, so CVE fixes land on every rebuild.
ARG SECURITY_AGENT_VERSION="latest.el9.x86_64"
ARG STATIC_CURL_VERSION
FROM quay.io/kairos-io/kairos-init:${KAIROS_INIT_VERSION} AS kairos-init
# ---- Alpine payload stage: everything that needs a package manager to
# assemble, done once. Hadron ships with NO package manager at all, so
# anything requiring `apk`/`rpm2cpio` has to be built here and copied into
# the final image as plain files.
# - Third-party security agent: unpacked via rpm2cpio+cpio (files only, no
# scriptlets) — safer than `rpm -ivh` when the target rootfs can't run
# the RPM's glibc-assuming post-install hooks. If it's glibc-linked,
# gcompat + its runtime deps (musl-obstack, libucontext) are pulled in
# and copied alongside it.
# - vmtoolsd (for reading VMware guestinfo at boot): Alpine's build is
# musl-linked, same libc family as Hadron, so it's extracted directly —
# no gcompat shim needed, unlike the glibc agent above.
# musl libc itself (ld-musl-*.so.1) is deliberately NOT copied for either —
# Hadron already provides its own, and overwriting it risks an ABI mismatch
# affecting the whole system.
FROM alpine:${ALPINE_VERSION} AS payloads
ARG SECURITY_AGENT_VERSION
RUN apk add --no-cache \
rpm curl gcompat libc6-compat libgcc musl-obstack libucontext \
open-vm-tools && \
mkdir -p /payload-root/usr/bin /payload-root/usr/lib && \
curl -fsSL \
"https://artifactory.example.com/security-agents/agent-${SECURITY_AGENT_VERSION}.rpm" \
-o /tmp/agent.rpm && \
( mkdir -p /agent-extract && cd /agent-extract && \
rpm2cpio /tmp/agent.rpm | cpio -idm --quiet && \
rm -f /tmp/agent.rpm && \
cp -a etc opt usr /payload-root/ ) && \
for pkg in gcompat libgcc musl-obstack libucontext; do \
for f in $(apk info -L "$pkg" | grep -E '^(lib(64)?|usr/lib)/'); do \
cp -aL "/$f" /payload-root/usr/lib/; \
done; \
done && \
cp -aL /usr/bin/vmtoolsd /payload-root/usr/bin/ && \
cp -aL /usr/lib/libvmtools.so.0 /usr/lib/libgmodule-2.0.so.0 \
/usr/lib/libgobject-2.0.so.0 /usr/lib/libglib-2.0.so.0 \
/usr/lib/libintl.so.8 /usr/lib/libtirpc-nokrb.so.3 \
/usr/lib/libffi.so.8 /usr/lib/libpcre2-8.so.0 \
/payload-root/usr/lib/
# ---- latest static musl curl, pinned via --build-arg ----
FROM ghcr.io/kairos-io/hadron-toolchain:${HADRON_VERSION} AS tools
ARG STATIC_CURL_VERSION
RUN curl -fsSL -o /curl \
"https://github.com/moparisthebest/static-curl/releases/download/v${STATIC_CURL_VERSION}/curl-amd64" \
&& chmod +x /curl
# ---- final: kairosified Hadron, core (no k3s) ----
FROM ghcr.io/kairos-io/hadron:${HADRON_VERSION} AS base
ARG VERSION
ARG MODEL
ARG TRUSTED_BOOT
LABEL org.opencontainers.image.title="Example Org Hadron Kairos Image"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.description="Example Org immutable Kairos Hadron (musl) base image"
LABEL io.kairos.family="hadron"
LABEL io.kairos.variant="core"
RUN --mount=type=bind,from=kairos-init,src=/kairos-init,dst=/kairos-init \
/kairos-init -l debug -s install --model "${MODEL}" -t "${TRUSTED_BOOT}" --version "${VERSION}" && \
/kairos-init -l debug -s init --model "${MODEL}" -t "${TRUSTED_BOOT}" --version "${VERSION}"
# Payload COPYs run AFTER kairos-init so its cleanup can't remove them.
COPY --from=tools /curl /usr/bin/curl
# Directories the immutable rootfs needs to exist so bind mounts declared in
# cloud-config (see 90-base.yaml below) have somewhere to land.
RUN mkdir -p \
/oem \
/system/oem \
/etc/k0s \
/opt/k0s \
/opt/cni/bin \
/var/lib/k0s/bin \
/var/lib/k0s/manifests \
/var/lib/k0s/images \
/var/lib/k0s/pki \
/var/lib/k0s/kubelet \
/var/lib/k0s/containerd \
/var/lib/k0s/etcd
# OEM cloud-configs — see the "Cloud-config anatomy" section below.
COPY cloud-config/90-base.yaml /system/oem/
COPY cloud-config/91-custom.yaml /system/oem/
COPY cloud-config/92-k0s.yaml /system/oem/
# configure-network is invoked directly by 91-custom.yaml's initramfs/boot
# stage commands.
COPY bin/configure-network.sh /opt/acme/configure-network
RUN chmod 0755 /opt/acme/configure-network
# Security agent + gcompat + vmtoolsd — all assembled in the payloads stage
# above; a single COPY here lands everything at once.
COPY --from=payloads /payload-root/ /
RUN mkdir -p /etc/systemd/system/multi-user.target.wants \
&& ln -sf /usr/lib/systemd/system/security-agent.service \
/etc/systemd/system/multi-user.target.wants/security-agent.service
A few things worth calling out if you diverge from this shape:
- Order matters for the final
COPY --from=payloads. It runs afterkairos-init, which performs its own cleanup pass — copying payloads earlier means kairos-init can silently delete them. gcompatis only needed for glibc-linked payloads. The musl-builtvmtoolsdextracted from Alpine doesn't need it — Alpine and Hadron are both musl, so it's a direct copy.- Pin
STATIC_CURL_VERSION(and any agent version) via--build-argat build time rather than hardcoding a default, so CI can resolve the latest patched release on every rebuild instead of the Dockerfile drifting behind it.
Cloud-config anatomy¶
Kairos's #cloud-config is layered — every file under /system/oem/ (or
passed via --cloud-config to auroraboot) is merged, later files
overriding earlier ones by key. Splitting by concern instead of one giant
file keeps each piece independently testable and reusable across variants
(Hadron/Alpine/RHEL, in the source project this guide is drawn from).
90-base.yaml — install + persistence¶
#cloud-config
install:
auto: true
device: /dev/sda
reboot: true
grub-entry-name: "Example Org — Kairos"
grub_options:
timeout: 3
eject-cd: true
# Directories that MUST exist in the immutable rootfs for the bind mounts
# below to have targets (created in the Dockerfile; listed here for
# documentation — Kairos does not create them for you).
extra-dirs-rootfs:
- /opt/acme
- /etc/k0s
- /opt/k0s
- /opt/cni/bin
- /var/lib/k0s
- /var/lib/k0s/bin
- /var/lib/k0s/manifests
- /var/lib/k0s/images
- /var/lib/k0s/pki
- /var/lib/k0s/kubelet
- /var/lib/k0s/containerd
- /var/lib/k0s/etcd
# Bind mounts overlay these with persistent storage — writable and
# preserved across reboots/upgrades, unlike the rest of the immutable rootfs.
bind_mounts:
- /opt/k0s # k0smotron downloads the k0s binary here
- /var/lib/k0s # k0s data directory (etcd, kubelet, etc.)
- /etc/k0s # k0s configuration
- /opt/cni/bin # CNI plugins installed by k0s
# tmpfs — fast, but cleared on every reboot. Fine for runtime data that
# doesn't need to survive one.
ephemeral_mounts:
- /run/k0s
- /tmp
91-custom.yaml — datasource, network, hardening¶
This is where stage if: conditionals earn their keep — Kairos's
cloud-config stages (built on yip) accept
an if: shell condition per step; the step only runs if it exits 0. The
network-configuration step below is the pattern from the earlier discussion
in this session: run something only once networking is actually up and an
IP has been assigned, instead of assuming it during initramfs.
#cloud-config
stages:
# initramfs runs after rootfs is mounted but before init starts — network
# config written here is picked up on first boot.
initramfs:
- name: "Configure network from guestinfo"
commands:
- /opt/acme/configure-network
boot:
- name: "Set the kairos datasource to VMware"
datasource:
providers:
- "vmware"
- name: "Mask unwanted network services"
commands:
- ln -sf /dev/null /etc/systemd/system/NetworkManager.service || true
- ln -sf /dev/null /etc/systemd/system/systemd-networkd-wait-online.service || true
- name: "Configure network from guestinfo"
commands:
- /opt/acme/configure-network
# Only proceed once networking actually has a global IP — guards any
# step that depends on outbound connectivity (registration callbacks,
# NTP checks, etc.) instead of racing systemd-networkd at boot.
- name: "Run once network is up with an IP"
if: '[ -n "$(ip -4 -o addr show scope global up 2>/dev/null)" ]'
commands:
- echo "network is up" >> /var/log/first-boot.log
- name: "Set falcon-sensor stop timeout (systemd)"
if: 'command -v systemctl > /dev/null 2>&1'
files:
- path: /etc/systemd/system/security-agent.service.d/override.conf
permissions: 0644
content: |
[Service]
TimeoutStopSec=30s
commands:
- systemctl daemon-reload
# Runs last so the integrity baseline reflects this VM's own first-boot
# state (network/hostname/hardening already applied above), not a
# database shared across every VM cloned from the same template. Guarded
# on the DB file existing so it initializes once, not on every boot —
# re-running --init each boot would silently reset real tampering.
- name: "Initialize AIDE integrity baseline (first boot only)"
if: 'command -v aide > /dev/null 2>&1 && [ ! -f /var/lib/aide/aide.db.gz ]'
commands:
- mkdir -p /var/lib/aide
- aide --init
- mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
after-install-chroot:
- name: "Mask unwanted network services (systemd)"
if: 'command -v systemctl > /dev/null 2>&1'
commands:
- mkdir -p /etc/systemd/system
- ln -sf /dev/null /etc/systemd/system/NetworkManager.service
- name: "Set GRUB timeout"
commands:
- echo "GRUB_TIMEOUT=3" >> /oem/grub_oem_env
92-k0s.yaml — orchestrator directories¶
#cloud-config
name: "k0s Base Setup"
stages:
after-install-chroot:
- name: "Setup k0s dirs"
directories:
- path: /opt/k0s
permissions: 0755
- path: /etc/k0s
permissions: 0755
- path: /run/k0s
permissions: 0755
- path: /opt/cni/bin
permissions: 0755
- path: /var/lib/k0s
permissions: 0755
configure-network.sh — guestinfo-driven networking¶
VMware's guestinfo interface is the standard way to hand a static IP,
gateway, and DNS to a VM at boot without a DHCP server or cloud-init's
NoCloud datasource. This script reads it via vmtoolsd (the binary
extracted in the Dockerfile's payloads stage) and writes either
systemd-networkd or ifupdown config depending on what's available —
POSIX sh only, since it has to run under both bash-as-/bin/sh (Hadron,
RHEL) and busybox ash (Alpine) with no bash installed.
#!/bin/sh
set -u
log() { echo "[configure-network] $*"; }
warn() { echo "[configure-network] WARNING: $*" >&2; }
guestinfo() {
vmtoolsd --cmd "info-get $1" 2>/dev/null || echo ""
}
IP=$(guestinfo guestinfo.network.ip)
PREFIX=$(guestinfo guestinfo.network.prefix)
PREFIX="${PREFIX:-24}"
GW=$(guestinfo guestinfo.network.gateway)
DNS=$(guestinfo guestinfo.network.dns)
DOMAIN=$(guestinfo guestinfo.network.domain)
DOMAIN="${DOMAIN:-corp.example.com}"
HOSTNAME=$(hostname -s 2>/dev/null | tr -d '\n\r')
case "${HOSTNAME}" in
kairos*|localhost|"")
HOSTNAME=$(guestinfo guestinfo.network.hostname)
[ -z "${HOSTNAME}" ] && HOSTNAME=$(hostname -s 2>/dev/null || echo "localhost")
;;
esac
FQDN="${HOSTNAME}.${DOMAIN}"
echo "${FQDN}" > /etc/hostname
hostname "${FQDN}" 2>/dev/null || true
if [ -n "${IP}" ] && [ -n "${GW}" ]; then
if command -v systemctl >/dev/null 2>&1; then
mkdir -p /etc/systemd/network
{
echo "[Match]"
echo "Name=en*"
echo ""
echo "[Network]"
echo "DHCP=no"
echo "Address=${IP}/${PREFIX}"
echo "Gateway=${GW}"
for server in $(echo "$DNS" | tr ',' ' '); do
echo "DNS=${server}"
done
} > /etc/systemd/network/10-static.network
systemctl restart systemd-networkd 2>/dev/null || true
fi
echo "${IP} ${FQDN} ${HOSTNAME}" >> /etc/hosts
log "Wrote network config: ${IP}/${PREFIX} gw=${GW}"
else
warn "IP or gateway not set in guestinfo — skipping network configuration"
fi
Building the ISO with auroraboot¶
Once the image builds and pushes:
docker run --rm --pull=always \
-v "$(pwd)/output:/tmp/auroraboot" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)/config/cloud-config:/cloud-config:ro" \
--privileged \
quay.io/kairos-io/auroraboot:latest \
--set "name=hadron-kairos-${VERSION}" \
--set "container_image=docker:registry.example.com/vm-images/hadron-kairos:${VERSION}" \
--set "artifact_version=${VERSION}" \
--set "disable_http_server=true" \
--set "disable_netboot=true" \
--set "state_dir=/tmp/auroraboot" \
--cloud-config /cloud-config/90-base.yaml
This produces output/hadron-kairos-${VERSION}.iso — a self-installing ISO
that boots, partitions the target disk, writes the immutable rootfs, applies
every merged cloud-config, and (per install.reboot/install.poweroff)
either reboots into the installed system or powers itself off.
Getting the ISO onto vSphere and templating it¶
export GOVC_URL="https://vcenter.example.com/sdk"
export GOVC_USERNAME="svc-banlieue"
export GOVC_PASSWORD="********"
export GOVC_DATACENTER="DC1"
# 1. Upload the ISO
govc datastore.mkdir -ds=DC1-cluster-01-DS001 hadron-kairos-iso
govc datastore.upload -ds=DC1-cluster-01-DS001 \
output/hadron-kairos-${VERSION}.iso hadron-kairos-iso/hadron-kairos-${VERSION}.iso
# 2. Create the VM, attach the ISO, power on
govc vm.create -m 4096 -c 2 -disk 40G -g rhel9_64Guest -net "VM Network" \
-ds DC1-cluster-01-DS001 -on=false hadron-kairos-build
govc device.cdrom.add -vm hadron-kairos-build
govc device.cdrom.insert -vm hadron-kairos-build \
-ds DC1-cluster-01-DS001 hadron-kairos-iso/hadron-kairos-${VERSION}.iso
govc vm.power -on hadron-kairos-build
# 3. Wait for the unattended install to power the VM off itself
# (install.poweroff: true in cloud-config — see ADR-0021 for the full
# contract if you're feeding this into banlieue instead of doing it by
# hand), then remove the ISO and mark as a template.
govc device.cdrom.eject -vm hadron-kairos-build
govc device.remove -vm hadron-kairos-build -keep=false cdrom-*
govc vm.markastemplate hadron-kairos-build
If you'd rather have this driven for you across every failure domain — with
retries, status conditions, and a VMImage CR as the source of truth instead
of a one-off shell session — that's exactly what banlieue-imagebuilder and
the vSphere provider do once you push the OCI image
and point a VMImage at it; see
Using banlieue-imagebuilder.
Gotchas¶
- No package manager on Hadron, period. If a build step reaches for
apk/dnf/rpm -ivhinside the final stage, it will fail — everything has to be assembled in thepayloadsstage andCOPY'd in as files. gcompatonly for glibc payloads. A musl-built binary (likevmtoolsdpulled from Alpine) doesn't need it; a glibc-linked one (most commercial agents) does, plus its own runtime deps.- Never copy
ld-musl-*.so.1from the payloads stage. Hadron already has its own; overwriting it risks an ABI mismatch for the entire system, not just the payload. - Payload
COPYs go afterkairos-init, or its cleanup pass deletes them. sshdonly honors the first occurrence of a directive across all itsIncluded config files. If Hadron already ships its own hardening drop-ins, name yours to sort before them (00-...beats99-...) or your overrides are silently ignored.- Kairos 3.3.x+ refuses to run its install stage without an
admin-group user anywhere in the merged cloud-config (users:withgroups: ["admin"], or explicitinstall.nousers: true). Skipping this looks identical to a missinginstall.poweroff/rebootpair — the install just never completes and whatever's waiting on it times out. Documented in detail in ADR-0021. - Gate the AIDE (or similar integrity-baseline)
--initon the database not already existing. Re-running it on every boot silently resets any real tampering it should have caught. TRUSTED_BOOT=trueon vSphere is experimental — expect an extremely slow first boot. Livegovc-driven testing (clone → vTPM attach → Secure Boot key pre-seed → cloud-config → power on) against a Debian-based Trusted Boot/UKI image found multi-minute silent stalls at each Secure Boot stage handoff (shim → systemd-boot → UKI), confirmed via ESXi'svmware.logshowing zero hypervisor-visible activity during the stalls — not vTPM emulation overhead, something inside guest space not yet root-caused. A follow-up attempt with a Hadron-based image also failed to boot cleanly and wasn't further diagnosed. See ADR-0051's follow-ups before relying on this for anything beyond experimentation.