Skip to content

Join a node from a local QEMU VM

Spin up a throwaway Linux VM on your laptop with QEMU and attach it to your control plane as a Kubernetes worker — no spare hardware, no cloud bill.

Want to kick the tires without dedicated hardware or a cloud instance? Run a throwaway Linux VM on your own machine with QEMU and attach it as a worker. It’s the same outbound-only join as any other node — this guide gives you the exact VM to run it in.

This is the hands-on companion to Join a node, which covers the join itself in more depth. If you already have a Linux box, use that guide instead — you don’t need a VM.

You’ll need: a Mac (Apple Silicon) or a Linux workstation, QEMU, about 4 GB of free RAM for the VM, and a control plane you can mint a join token for. The VM needs only outbound internet — the same as any node.

The examples target Apple Silicon (arm64); notes call out where an Intel/Linux host differs. A node’s architecture is independent of your control plane — an arm64 worker joins an amd64-managed cluster fine.

1. Install QEMU

brew install qemu            # macOS
# sudo apt install qemu-system-arm ovmf   # Debian/Ubuntu host

2. Get an Ubuntu cloud image

Cloud images ship with cloud-init, which we use to drop in an SSH key on first boot. Grab the current Ubuntu LTS for your VM’s architecture:

mkdir -p ~/flotilla-vm && cd ~/flotilla-vm
curl -fSLO https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img

Make a resized overlay so the base image stays pristine and the VM has room for container images:

qemu-img create -f qcow2 -F qcow2 -b noble-server-cloudimg-arm64.img node.qcow2 20G

3. Seed cloud-init and boot

Generate a key and a tiny cloud-init seed that authorizes it for the default ubuntu user:

ssh-keygen -t ed25519 -N '' -f id_ed25519

mkdir seed
cat > seed/meta-data <<'EOF'
instance-id: flotilla-node-1
local-hostname: flotilla-node-1
EOF
cat > seed/user-data <<EOF
#cloud-config
users:
  - name: ubuntu
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    ssh_authorized_keys:
      - $(cat id_ed25519.pub)
EOF

# The seed must be an ISO labelled CIDATA (macOS: hdiutil; Linux: cloud-localds).
hdiutil makehybrid -iso -joliet -default-volume-name CIDATA -o seed.iso seed
# Linux: cloud-localds seed.iso seed/user-data seed/meta-data

Boot it, daemonized, forwarding host port 2222 to the VM’s SSH:

cp /opt/homebrew/share/qemu/edk2-arm-vars.fd efi-vars.fd
qemu-system-aarch64 \
  -machine virt,accel=hvf,gic-version=3 -cpu host -smp 2 -m 6144 \
  -drive if=pflash,format=raw,readonly=on,file=/opt/homebrew/share/qemu/edk2-aarch64-code.fd \
  -drive if=pflash,format=raw,file=efi-vars.fd \
  -drive if=virtio,format=qcow2,file=node.qcow2 \
  -drive if=virtio,format=raw,file=seed.iso \
  -netdev user,id=net0,hostfwd=tcp::2222-:22 -device virtio-net-pci,netdev=net0 \
  -display none -serial file:serial.log -pidfile qemu.pid -daemonize

Intel Mac / Linux host: swap qemu-system-aarch64 for qemu-system-x86_64, use accel=kvm on Linux (accel=hvf on Intel macOS), drop gic-version=3, and point the firmware drives at your host’s OVMF (/usr/share/OVMF/OVMF_CODE.fd). Use the amd64 cloud image to match.

Give cloud-init a minute, then SSH in:

ssh -i id_ed25519 -p 2222 ubuntu@127.0.0.1

4. Get the join command

From your workstation (not the VM), mint a join and print the one-liner:

lbr cp node attach test

Or copy it from the Console — the cluster’s Attach a node card shows the same command. Either gives you:

curl -fsSL https://api.eu-hel.cp.lngbrdg.com/install.sh \
  | sudo bash -s -- --token <token> --k8s-version 1.36

5. Join from inside the VM

Paste that one-liner into the VM’s SSH session and run it as root. The installer pulls the prerequisites (containerd, kubeadm/kubelet matched to your cluster’s Kubernetes version, and the outbound fabric tunnel), brings the tunnel up, and joins:

# inside the VM
curl -fsSL https://api.eu-hel.cp.lngbrdg.com/install.sh \
  | sudo bash -s -- --token <token> --k8s-version 1.36

It ends with node 'flotilla-node-1' is joining. The control plane installs the CNI for you and approves the node.

6. Verify

From your workstation:

lbr cp node list test      # or: kubectl get nodes

flotilla-node-1 shows up and goes Ready once the CNI settles. Schedule something to prove it out:

kubectl run hello --image nginx --restart=Never
kubectl get pod hello -o wide     # lands on flotilla-node-1

Tear down

kill "$(cat ~/flotilla-vm/qemu.pid)"     # stop the VM
lbr cp node remove test flotilla-node-1  # drop it from the cluster

Because the VM is a throwaway overlay, deleting node.qcow2 and re-running step 3 gives you a clean node every time.

Notes

  • Outbound-only. The VM’s user-mode NIC gives it internet with no inbound ports; the node reaches the apiserver by dialing out to the shard’s fabric gateway. No host port-forwarding beyond SSH is needed for Kubernetes.
  • Re-joining. A node that already joined won’t re-join in place — the installer refuses if /etc/kubernetes/kubelet.conf exists. Reset the overlay (step 3) for a fresh node.
  • Resources. 2 vCPU / 4–6 GB is comfortable for a worker running a few pods. Drop to 2 GB only for the smallest tests.