From nothing to a working Kubernetes node in four steps, and a fifth for managing it without SSH.

The short version, if you are in a hurry:

oras pull ghcr.io/corium-os/corium-qcow2:0.1.0   # download a ready-made disk
printf '#cloud-config\ncorium:\n  role: single\n' > node.yaml
# boot the disk with node.yaml as cloud-init user-data

The rest of this page explains each step, and the last section covers the mistakes that cost the most time.


If you would rather understand the model before typing anything, read concepts first.

Before you start

Every release publishes a ready-made, signed disk image, so the quick path needs no build host at all. You only need two things:

  • Somewhere to run the node — Proxmox, KVM/libvirt, or any cloud that accepts a qcow2 or an ISO.
  • A way to download itoras, curl, or a browser. Any of the three works from macOS, Windows or Linux, and none needs sudo.

You do not need podman, a Linux host, or 20 GB of build space unless you mean to change the image and rebuild it yourself — that path is covered in build your own image at the end.


1. Get a bootable disk

Pull the artefact that matches where the node will run:

ArtefactPull it withUse it for
qcow2 diskoras pull ghcr.io/corium-os/corium-qcow2:0.1.0Proxmox, KVM, libvirt
Installer ISOoras pull ghcr.io/corium-os/corium-iso:0.1.0Bare metal. Installs unattended

The tag above is only an example. The exact coordinates for a given version, with their digests, are on that version’s release page — they change every release, so they are published with it rather than written down here.

No oras? A plain curl or a browser download works just as well. See downloads, which also covers how to check that what you received is what was published:

cosign verify ghcr.io/corium-os/corium-qcow2:0.1.0 \
  --certificate-identity-regexp 'https://github.com/Corium-OS/Corium/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

The ISO is unattended: it deploys the image embedded in it with no prompts and no kickstart to write.

Changed the image and need your own disk instead? Build a qcow2, a raw disk or an ISO from it — see build your own image.


2. Write a node configuration

A complete single-node cluster:

#cloud-config
corium:
  role: single

users:
  - name: core
    groups: [wheel]
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... you@example.com

role is the only required field. Everything else has a default, and the document stays an ordinary cloud-config — write_files, runcmd and the rest keep working.

If you have the repository checked out, validate it before you boot anything:

go run ./cmd/corium-agent validate node.yaml

Validation is offline and reports every problem at once, so you do not discover your mistakes one reboot at a time.

The four roles:

RoleWhat it is
singleA self-contained cluster. Cannot gain nodes later
controllerControl plane only, runs no workloads
controller+workerControl plane that also accepts workloads
workerWorkloads only; joins an existing cluster with a token

See examples/ for workers, add-ons, a custom CNI, and HA.


3. Boot it

Proxmox

# on the Proxmox node
VMID=140 \
DISK_IMAGE=/path/to/disk.qcow2 \
CLOUD_CONFIG=/path/to/node.yaml \
IP_CONFIG="ip=192.168.0.190/24,gw=192.168.0.1" \
NAMESERVER=192.168.0.1 \
  bash deploy/proxmox/create-vm.sh

qm start 140

Installing from the ISO instead uses deploy/proxmox/create-vm-iso.sh, which takes the same variables plus ISO=local:iso/corium-install.iso.

Anywhere else

Boot the disk and hand node.yaml to the platform as cloud-init user-data. Corium probes NoCloud, ConfigDrive, OpenStack, EC2, Azure, GCE, Hetzner, VMware and OVF, so one image works across all of them.

For a NoCloud seed ISO:

printf 'instance-id: node-01\nlocal-hostname: node-01\n' > meta-data
cp node.yaml user-data
genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data

Without cloud-init

Bare metal with no seed device, PXE, or a preconfigured appliance: put the configuration where the agent will find it. Sources are tried in this order and the first that answers wins.

SourceFor
/etc/corium/config.yamlAn operator’s answer for this machine
cloud-initClouds and hypervisors
corium.config= on the kernel command linePXE and netboot
/usr/share/corium/config.yamlA default baked into a derived image

Outside a cloud-config, write the schema on its own — no corium: wrapper:

role: worker
cluster:
  name: edge
join:
  tokenFrom:
    url: https://secrets.example.com/corium/worker-token

4. Check it worked

ssh core@192.168.0.190

systemctl status corium-bootstrap    # what the agent did, and why if it failed
sudo k0s kubectl get nodes
sudo k0s kubectl get pods -A

A single node takes roughly a minute from power-on to Ready. The API answers well before the node registers, so Ready is the milestone to wait for.

Use the cluster from your workstation:

ssh core@192.168.0.190 sudo k0s kubeconfig admin > kubeconfig
KUBECONFIG=./kubeconfig kubectl get nodes

The address in that file is whatever k0s decided, which is the node’s own — right for a single node, wrong for a cluster with a virtual IP, where a kubeconfig aimed at one controller stops working the first time that controller does. Check the server: field before you rely on it. Step 5 removes that worry.


5. Manage it without SSH (optional)

Everything above works over SSH, and on an immutable OS that is a poor fit: the shell you land in is a shell over a system where almost nothing you type persists. Corium ships a management API for the things you actually want — reading a node, restarting k0s, upgrading it, getting a kubeconfig — and a client called cctl.

It is off unless you ask for it. A node with no api: block runs no daemon and binds no port, which is what the four steps above produced.

cctl has no release artefact yet, so build it from a checkout:

mise run build          # produces bin/cctl

Make the operator CA. Its certificate is what nodes are told to trust; the key beside it stays on your machine and is never sent anywhere:

cctl pki init
cctl pki issue --role admin

pki init prints the certificate ready to paste. Add it to the node configuration from step 2 — it is a certificate, not a secret, so it is safe in cloud-init in the open:

corium:
  role: single
  api:
    operatorCA: |
      -----BEGIN CERTIFICATE-----
      ...
      -----END CERTIFICATE-----

Reprovision the node with that configuration, and it claims itself at boot. Then, from your workstation:

cctl status 192.168.0.190          # role, image digest, k0s, greenboot, uptime
cctl logs 192.168.0.190 --unit k0scontroller --since 15m
cctl kubeconfig 192.168.0.190 > kubeconfig

The first call asks you to confirm the node’s fingerprint against its journal, and remembers it; a node signs its own certificate, so the fingerprint is what identifies it rather than its name. cctl kubeconfig points the file at the cluster’s virtual IP where there is one, which is the worry step 4 leaves you with.

The cctl page covers every command, the three roles, and what the API deliberately will not do.


Where to go next

  • Add workersexamples/worker.yaml. Mint a token on the controller with k0s token create --role=worker --expiry=1h.
  • Highly available control planeexamples/ha-controller-first.yaml, or deploy/proxmox/create-ha-cluster.sh to bootstrap all three at once. No load balancer and no certificates required.
  • Add-ons — declare Helm charts under addons: and k0s installs them at bootstrap. No Helm binary, no in-cluster operator.
  • A different CNIinstalling Cilium, start to finish.
  • Anything Corium does not modelk0s.patch is applied verbatim to the rendered k0s.yaml, so every k0s setting stays reachable.
  • Every field in detail — the configuration reference.
  • What is and is not supportedfeature support, including everything reachable through the k0s passthrough.

Upgrades

Kubernetes ships with the OS, so upgrading means booting a new image. The upgrades guide covers doing this to a cluster without losing quorum; the short version:

sudo bootc upgrade --apply     # reboots into the new version
sudo bootc rollback            # if it went badly

Drain the node first if it carries workloads you care about.


Troubleshooting

The node boots and does nothing. Read the journal: journalctl -u corium-bootstrap, or cctl logs <node> --unit corium-bootstrap if you did step 5. A node with no Corium configuration says so and stops, which is a valid outcome — you get a host, not a Kubernetes node.

cctl says the node is not enrolled. It is waiting to be claimed: api.enabled: true with no CA is maintenance mode, and such a node holds its bootstrap until somebody runs cctl enroll with the pairing code from its console. If you meant it to come up on its own, give it api.operatorCA instead.

The node is NotReady and stays there. If you set cni: custom, this is expected: nothing has installed a network yet and the cluster is waiting for you. Otherwise check kubectl -n kube-system describe pod for the CNI DaemonSet.

Images will not pull, everything else works. Almost always DNS. A static address configured through a hypervisor usually carries no resolver — Proxmox’s ipconfig0 has no field for one — so set NAMESERVER. The symptom is misleading: the node pings, SSH works, the API answers, and Kubernetes hangs with lookup quay.io: Try again.

Installing from the ISO loops forever. The boot order must be disk first, ISO second. An empty disk has no UEFI boot entry, so the firmware falls through to the ISO and installs; afterwards the disk has an entry and wins. With the ISO first, the node reinstalls itself on every reboot.

Do not stop the VM during an install, either. Anaconda wipes the disk early, so an interrupted install leaves nothing bootable behind.

Two nodes have the same name. They should not: Corium derives a stable name from the machine ID when the hostname is still generic. If you cloned a disk after first boot, the machine ID came along with it — clear /etc/machine-id on the clone, or set node.name explicitly.


Build your own image

Everything above uses a published image. You only need this section when you have changed the image and want a disk built from your own version.

This is the one part that needs a Linux host with podman and about 20 GB of free disk, plus mise, which runs every command below and provisions the toolchain they need. mise install once, in the checkout.

macOS and Windows cannot build the disk images. bootc-image-builder mounts the root filesystem it creates in order to populate it, which needs a real Linux kernel. A Linux VM is fine; so is the hypervisor you are deploying to, which is often the most convenient place.

podman --version     # 4.x or newer
nproc; free -g       # 2 cores and 4 GB are enough to build

The Go toolchain is not required: the agent is compiled inside the container build.

Build the OS image

mise run image

This produces an ordinary OCI image. Inspect it like any other:

podman run --rm localhost/corium:dev k0s version
podman run --rm localhost/corium:dev corium-agent version

The build ends with bootc container lint. If it reports warnings, read them — they catch real problems, such as content written to /var that will not survive an upgrade.

To publish it:

REGISTRY=ghcr.io/you IMAGE_TAG=v0.1.0 mise run push

Turn it into a disk

Pick the artefact that matches where the node will run:

CommandProducesUse it for
mise run artefact-qcow2output/qcow2/disk.qcow2Proxmox, KVM, libvirt
mise run artefact-rawoutput/image/disk.rawBare metal, most cloud import paths
mise run artefact-anaconda-isooutput/bootiso/install.isoBare-metal installs
mise run artefactsall three

Each takes several minutes and needs sudo, because the builder runs privileged. From here, pick up at step 2 with the disk you just built.