Boot a node CVM on bare metal with QEMU

Pull the measured c8s node image, launch it as an AMD SEV-SNP or Intel TDX confidential VM under QEMU on hardware you own, and confirm it attests before you install c8s into it.

This guide launches the measured c8s node image as a CVM (confidential VM) under plain QEMU on your own hardware. The result is one CVM running a single-node RKE2 cluster whose launch state you can pin, and which hands you an attested admin kubeconfig.

The hypervisor is yours here, and it is still outside the trust boundary: the guest proves what it booted, the host is never asked. For what that boundary covers, read Node-as-CVM. For the full list of what the image expects from a hypervisor, see the node CVM launch contract.

Before you start

On the host — the machine with the TEE (Trusted Execution Environment):

  • An AMD SEV-SNP or Intel TDX host with the TEE enabled in firmware and in the kernel. Linux ≥ 6.11 for SEV-SNP, ≥ 6.16 for TDX.
  • QEMU 10.1 or newer, built with --enable-igvm, for SEV-SNP. The SEV-SNP node image boots through IGVM, which most distributions do not build in. Intel TDX boots TDVF from the same artifact, so a QEMU with TDX support is enough there.
  • oras, openssl, genisoimage, qemu-img, curl, and jq.
  • Root on the host: launching an SEV-SNP guest opens /dev/sev.
  • Disk: the artifact unpacks to about 3.5 GB, and the guest's scratch and cache disks start sparse and grow to whatever you size them.

On the machine you drive the cluster from — usually the same host:

  • The c8s CLI, at the version the node image was built for. The image bakes an admission floor containing the component digests of its own build, so a mismatched CLI has its own components denied.
  • Outbound HTTPS to AMD's key distribution service (kdsintf.amd.com) on SEV-SNP: verification fetches the VCEK certificate for the reporting CPU from there.

Check the host TEE before you build anything:

# AMD SEV-SNP: all three must print Y
cat /sys/module/kvm_amd/parameters/{sev,sev_es,sev_snp}
qemu-system-x86_64 -object igvm-cfg,help

# Intel TDX: must print Y
cat /sys/module/kvm_intel/parameters/tdx

A QEMU with IGVM support prints the igvm-cfg object's properties; a build without it answers Parameter 'qom-type' does not accept value 'igvm-cfg'.

If SEV-SNP is off, the fix is in firmware and menu names differ by vendor: enable SMEE, enable the IOMMU, enable SNP memory coverage (this reserves the reverse map table), and raise the SEV-ES ASID space limit above 1. On the host kernel cmdline, kvm_amd.sev_snp=1 turns the module knob on and iommu=pt defeats SNP.

Boot the node CVM

Pull the image artifact

The node image is published as an OCI artifact of raw files. Pull the tag for your platform — rke2-snp on AMD SEV-SNP, rke2-tdx on Intel TDX:

oras pull ghcr.io/confidential-dot-ai/node-guest-base:rke2-snp -o ./guest
ls ./guest

The SEV-SNP artifact carries OVMF.fd, disk.raw, uki.efi, combined-initrd.img, dsdt.aml, roothash, manifest.json, and one measured IGVM file per supported vCPU count: guest-smp2.igvm, guest-smp4.igvm, guest-smp8.igvm, guest-smp16.igvm.

The Intel TDX artifact carries OVMF.tdx.fd in place of OVMF.fd and ships no IGVM file: the guest boots TDVF, and its identity is the MRTD plus RTMR[1] and RTMR[2] tuple in manifest.json.

The package is public, so the pull needs no credentials. disk.raw is about 3 GB. The -cdi tags beside these are the KubeVirt disk-image form of the same build.

Create the operator key and the opkeydata disk

The operator key is the identity the node is launched to trust. Generate it, then put its public half on an ISO9660 disk whose volume label is opkeydata and whose single file is named pubkey:

openssl ecparam -name prime256v1 -genkey -noout -out operator.key
openssl ec -in operator.key -pubout -out operator.pub

mkdir -p opkeydata && cp operator.pub opkeydata/pubkey
genisoimage -quiet -rock -joliet -volid opkeydata -output opkey.iso opkeydata/
isoinfo -R -l -i opkey.iso

isoinfo must list a lowercase pubkey. Rock Ridge (-rock) is what preserves the name; a plain ISO9660 disk presents it as PUBKEY.;1 and the guest will not find it.

Without this disk there is no kubeconfig, and nothing looks broken.

The guest's credential-release service carries ConditionPathExists=/dev/disk/by-label/opkeydata and ConditionPathExists=/run/confos/role-server. An unmet condition makes systemd skip the unit rather than fail it, so a node launched without the disk boots, joins itself, and attests exactly like a healthy one — while c8s get-kubeconfig never gets a connection on 8443. Attach the disk at launch, and note that only the server role serves credentials.

The pubkey file is hashed verbatim: on SEV-SNP its SHA-256 is what the launcher commits as HOSTDATA, and on Intel TDX the measured initrd hashes it and extends the digest into RTMR[3]. Copy the file openssl ec -pubout wrote, armor and trailing newline included. Any re-encoding changes the digest and the gate fails.

Create the data disks

The scratch disk holds the guest's encrypted overlay — the writable layer RKE2's /var/lib/rancher lands on. It is a launch requirement: a guest with no disk carrying the serial confai-scratch falls back to a 2 GB tmpfs, and RKE2 wedges when that fills. Give it at least 64 GB:

qemu-img create -f raw scratch.raw 64G
qemu-img create -f raw containerd.raw 100G

containerd.raw is optional and keeps the containerd image cache off guest RAM. The guest claims it by device serial confai-containerd, or by the filesystem label containerd on a disk you prepared yourself; with neither, the cache lands on a 32 GB tmpfs. Both disks are handed over blank — the guest formats and encrypts them per boot with a key that never leaves the CVM.

Launch the CVM

Run the invocation for your platform. Both list the root disk first and give it bootindex=0, both attach the root read-only (a read-only image takes a shared QEMU lock, so several CVMs can run off one copy), and both use the split -drive if=none plus -device form, which is where a disk serial is accepted.

AMD SEV-SNP

SEV-SNP boots the measured IGVM file, and the operator key is bound at launch through HOSTDATA:

HOSTDATA=$(openssl dgst -sha256 -binary operator.pub | base64)

sudo qemu-system-x86_64 \
  -enable-kvm \
  -cpu EPYC-Genoa \
  -machine q35,confidential-guest-support=sev0,igvm-cfg=igvm0,memory-backend=ram1,kernel-irqchip=split \
  -object igvm-cfg,id=igvm0,file=./guest/guest-smp4.igvm \
  -object memory-backend-memfd,id=ram1,size=16G,share=on,prealloc=off \
  -object sev-snp-guest,id=sev0,cbitpos=51,reduced-phys-bits=1,host-data="$HOSTDATA" \
  -smp 4 -m 16G \
  -nodefaults -no-reboot -display none -serial none \
  -drive id=root0,file=./guest/disk.raw,format=raw,if=none,readonly=on \
  -device virtio-blk-pci,drive=root0,bootindex=0 \
  -drive id=scratch0,file=./scratch.raw,format=raw,if=none,cache=none \
  -device virtio-blk-pci,drive=scratch0,serial=confai-scratch \
  -drive id=opkey0,file=./opkey.iso,format=raw,if=none,readonly=on \
  -device virtio-blk-pci,drive=opkey0 \
  -device virtio-scsi-pci,id=scsi0 \
  -drive id=cache0,file=./containerd.raw,format=raw,if=none \
  -device scsi-hd,drive=cache0,bus=scsi0.0,serial=confai-containerd \
  -netdev user,id=net0,hostfwd=tcp::6443-:6443,hostfwd=tcp::8400-:8400,hostfwd=tcp::8443-:8443,hostfwd=tcp::443-:443 \
  -device virtio-net-pci,netdev=net0 \
  -qmp unix:./qmp.sock,server=on,wait=off

cbitpos is the C-bit position from CPUID leaf 0x8000001F, EBX[5:0] — 51 on current EPYC parts, and worth reading off your own CPU (cpuid -l 0x8000001f -1) rather than copying. host-data is the base64 of SHA-256(pubkey), which is exactly what the attestation gate recomputes from operator.pub. share=on on the memory backend is what SEV-SNP and IGVM require; guest RAM is not part of the launch measurement, so 16 GB (the image's build default) can be raised without changing the digest.

-smp must match the IGVM file you pass.

An SEV-SNP launch measurement covers the initial vCPU state, so one image has one digest per vCPU count and the build ships one measured IGVM for each of 2, 4, 8, and 16. Boot guest-smp4.igvm at -smp 8 and the guest still boots — it then attests a digest that describes a VM it is not, and gets refused credentials. Pin snp_variants[smp=<N>].measurement.snp_launch_digest for the count you actually launch.

Intel TDX

TDX loads TDVF as firmware and takes no launch-time key binding; the measured initrd extends the pubkey digest into RTMR[3] after launch instead. -cpu host is mandatory — QEMU rejects a named CPU model for a trust domain:

sudo qemu-system-x86_64 \
  -enable-kvm \
  -cpu host \
  -machine q35,confidential-guest-support=tdx0,kernel-irqchip=split,hpet=off \
  -object tdx-guest,id=tdx0 \
  -bios ./guest/OVMF.tdx.fd \
  -smp 4 -m 16G \
  -nodefaults -no-reboot -display none -serial none \
  -drive id=root0,file=./guest/disk.raw,format=raw,if=none,readonly=on \
  -device virtio-blk-pci,drive=root0,bootindex=0 \
  -drive id=scratch0,file=./scratch.raw,format=raw,if=none,cache=none \
  -device virtio-blk-pci,drive=scratch0,serial=confai-scratch \
  -drive id=opkey0,file=./opkey.iso,format=raw,if=none,readonly=on \
  -device virtio-blk-pci,drive=opkey0 \
  -device virtio-scsi-pci,id=scsi0 \
  -drive id=cache0,file=./containerd.raw,format=raw,if=none \
  -device scsi-hd,drive=cache0,bus=scsi0.0,serial=confai-containerd \
  -netdev user,id=net0,hostfwd=tcp::6443-:6443,hostfwd=tcp::8400-:8400,hostfwd=tcp::8443-:8443,hostfwd=tcp::443-:443 \
  -device virtio-net-pci,netdev=net0 \
  -qmp unix:./qmp.sock,server=on,wait=off

This loads TDVF with -bios. Firmware loading is the one place TDX-capable QEMU builds differ, so match the form your build documents.

Both invocations use QEMU's user-mode network and forward the four ports an operator needs: 6443 (kube-apiserver), 8400 (attestation-api), 8443 (credential release), and 443 (the tls-lb front door, once c8s is installed). Those host ports must be free — a Kubernetes distribution already running on the host owns 6443. User-mode networking also gives the guest a default route, which it needs twice: systemd-timesyncd syncs the guest clock over it (a guest clock minutes behind the operator's trips the credential-release token check), and the baked admission inventory advertises the address of that route as the one the CDS dials back on, port 1019. For a cluster of more than one CVM, replace -netdev user with a bridge and tap so the guests share an L2 segment.

Confirm the guest is a genuine confidential VM

Two checks, from the host. First, that QEMU really created a confidential guest — grep the live command line for the guest object (sev-snp-guest on SEV-SNP, tdx-guest on Intel TDX):

tr '\0' '\n' < /proc/$(pgrep -f qemu-system-x86_64 | head -1)/cmdline | grep -c sev-snp-guest

A count of 1 means the guest is a real CVM; 0 means QEMU fell back to an ordinary VM. Second, that the guest is up and can produce evidence. The attestation-api answers on 8400 once the guest has booted, which takes a few minutes on a first boot:

curl -fsS http://127.0.0.1:8400/health

To read what the hardware actually measured, post a fresh nonce to /attest and run the evidence through c8s verify — the same request the kubeconfig gate makes:

NONCE=$(head -c 32 /dev/urandom | base64 -w0)
curl -fsS -H 'Content-Type: application/json' \
  -d "{\"platform\":\"auto\",\"report_data\":\"$NONCE\"}" \
  http://127.0.0.1:8400/attest > evidence.json

ERD=$(printf '%s' "$NONCE" | base64 -d | od -An -tx1 -v | tr -d ' \n')
c8s verify --from-file evidence.json --expected-report-data "$ERD" --output json | jq -r .measurement

On SEV-SNP that value must equal the snp_launch_digest your -smp count selects in ./guest/manifest.json. Use it to check the published digest, not to establish trust — pinning what a node reports about itself is trust on first use. See Obtain launch measurements.

Get an attested kubeconfig

c8s get-kubeconfig attests the node against the image manifest, proves the node was launched to trust your key, and only then exchanges a CSR for an admin client certificate over RA-TLS (Remote-Attestation TLS):

c8s get-kubeconfig \
  --node 127.0.0.1 \
  --operator-key operator.key \
  --image-manifest ./guest/manifest.json \
  --out cvm.kubeconfig

--node fills in the three URLs at 8400, 8443, and 6443. The manifest's shape selects the platform and pins every value in it, so no measurement flags are involved. Flags: c8s get-kubeconfig.

Confirm the identity and the node:

export KUBECONFIG=./cvm.kubeconfig
kubectl auth whoami
kubectl get nodes

kubectl auth whoami reports the username operator, and the node registers as c8s-node in Ready. The certificate is valid for 24 hours; re-run the command for a new one.

The entrypoint is the network

The node image is console-dead by design: the guest kernel is built without the 8250 serial driver, no getty is configured, there is no sshd, root is locked, and cloud-init is disabled, so a host-supplied cidata disk stays inert. Nothing about the guest is meant to be reachable except over the network it serves.

Debug it over that network:

  • curl http://127.0.0.1:8400/health — the guest is up and can attest.
  • c8s get-kubeconfig — then kubectl for everything inside.
  • The QMP socket from the launch line — VM-level lifecycle, including a clean system_powerdown and hot-attaching a disk to a running node.

Add more nodes

A CVM launched with no joindata disk is a single-node server, which is the shape above. To run more than one, give every CVM an ISO9660 disk labelled joindata whose files carry the role and tokens, and put the guests on a network where they can reach each other — a bridge and tap in place of the user-mode -netdev. The image bakes one hostname, so every node past the first must set node-name.

Generate the two tokens once:

openssl rand -hex 32 > server-token
openssl rand -hex 32 > agent-token

Then build one disk per node — a server:

mkdir -p joindata-server
printf 'server'          > joindata-server/role
printf 'c8s-node-1'      > joindata-server/node-name
cp server-token            joindata-server/server-token
cp agent-token             joindata-server/agent-token
genisoimage -quiet -rock -joliet -volid joindata -output joindata-server.iso joindata-server/

and an agent, which dials the server's RKE2 supervisor on 9345:

mkdir -p joindata-agent
printf 'agent'           > joindata-agent/role
printf 'c8s-node-2'      > joindata-agent/node-name
printf '<SERVER_IP>'     > joindata-agent/server
cp agent-token             joindata-agent/agent-token
genisoimage -quiet -rock -joliet -volid joindata -output joindata-agent.iso joindata-agent/

Attach the ISO like the opkeydata one. Every field is validated. A file the role does not allow, interior whitespace, a token that is not 64 lowercase hex characters, or two identical tokens fails the role unit, and every role-gated unit stays down with it. The field list is in the launch contract.

Troubleshooting

get-kubeconfig gets no answer on 8443. The credential-release unit was skipped. Confirm the opkeydata disk is attached and its volume label is exactly opkeydata, and that this CVM is the server (an agent has no CA to sign with).

get-kubeconfig fails with EOF from the release endpoint. On SEV-SNP the verifier fetches the VCEK from AMD's KDS on both the attestation gate and the RA-TLS dial, and a slow or rate-limited fetch outruns the guest's handshake timeout. Re-run the command. If it keeps failing, reboot the guest: the unit allows five failed starts in five minutes and then stops trying until the next boot.

The node goes NotReady under load, or RKE2 stalls. The scratch disk is missing or too small. Check that a disk with serial confai-scratch, at least 64 GB, is attached.

A secondary data disk hangs every I/O. Move it from virtio-blk to virtio-scsi; the guest ships the virtio-SCSI driver and the image's own cache disk prefers that bus.

tls-lb stays Pending after install — "didn't have free ports for the requested pod ports". Something else on the node owns host port 443. See Troubleshoot.

Next steps

Install c8s into the cluster you just booted, with the launch digest pinned from the manifest you already have:

M=$(jq -r '.snp_variants[] | select(.smp == 4) | .measurement.snp_launch_digest' ./guest/manifest.json)

c8s install \
  --cvm-mode=node \
  --hardware-platform=sev-snp \
  --single-node \
  --operator-keys operator.pub \
  --measurements "$M"

On Intel TDX, pin .tdx.mrtd from the manifest and pass --hardware-platform=tdx. For several images at once, c8s measurements derive turns their manifests into a measurements config. --cvm-mode=node leaves the attestation-api and the admission inventory to the image, which bakes both under its launch digest, and the node self-labels its platform at boot, so there is nothing to label by hand. Install the c8s release the image was built for.

The rest of the flow is the same as any other cluster: Install c8s for the full install, then Run a confidential workload and Verify a deployment.