Skip to content
Development docs, for main @ c56b482 - what's coming, not yet released. This page for v2026.10.05-5, the latest release →

Provisioning a new self-registering node

Four ways to create a Janus node that registers itself with an existing Controller: on its first boot, the node announces itself and shows up in the Controller’s approvals. To a Controller whose fleet is set up (The Controller) it sends no key at all - its CA’s certificate, and a secret it then polls with until it’s approved, when it takes the fleet’s trust; to one without a fleet, or older, a service certificate it has just created.

  • Method 1 - shared image + seed-controller: recommended for several nodes that share the same environment/Controller. One generic image, seeded once, never rebuilt per node.
  • Method 2 - Install per node: the full path, useful for a single one-off node, or when the rootfs itself has to differ from one node to the next (different versions/content - Install writes the rootfs as well as the config).
  • Method 3 - NoCloud volume (cidata): recommended when Terraform (or any other IaC tool) already drives provisioning - the image stays fully generic and shared (never touched), and the config lives on a small separate volume, generated by the same tool that already generates cloud-init for your other VMs. No janusctl command to run for the node at all.
  • Method 4 - the installer ISO: for a physical machine (USB stick) or a VM installed from a medium. The ISO boots a temporary Janus that installs the machine’s disk, already configured with the Controller.

Or let the Controller do it all on a libvirt/KVM host it’s been given: it creates the virtual machine with a NoCloud volume of its own (method 3, plus a one-time registration token) and admits the node without the approval step - see Hypervisors.

The Controller’s address and CA certificate are in its Provision new nodes with this Controller panel (or GET /api/controller-info) - and, once its fleet is set up, the fleet’s root (fleet-root.crt). Give a node of this release on the root too: -controller-fleet-root fleet-root.crt (lifecycle install, image seed-controller), or controller_fleet_root_cert in NoCloud user-data. The node then checks the Controller through its fleet - asking for the fleet’s certificate, controller.fleet.janus - before the CA certificate, and only takes that fleet’s trust; the CA certificate stays for a Controller that has no fleet yet, and for older images, which ignore the root.

A batch without approval: enrollment tokens. On the Controller’s Nodes page (admins), Enrollment tokens makes a token that admits up to a number of nodes before a date - a rack, bare metal - without the approval step, each labelled with the token’s labels (so the grants that pick them). Give it to the nodes as their registration token: -registration-token TOKEN on janusctl lifecycle install or image seed-controller, or registration_token in NoCloud user-data (the Provision panel puts it in its commands). A node presents it once; the next one past its uses, or after its date, or once it’s revoked, waits for approval like any other. Only the token’s SHA-256 is kept.

Without a Controller, give nodes a fleet janusctl keeps instead (A fleet without a Controller): janusctl image seed-fleet, lifecycle install -fleet-root -fleet-bundle, or fleet_root_cert + fleet_bundle in NoCloud user-data - the node trusts the fleet from its first boot, and janusctl fleet adopt -ca-fingerprint takes it in on the fingerprint its console shows.

Section titled “Method 1: shared image + seed-controller (recommended)”

How it works: image/kvm-proxmox/assemble.sh (or make proxmox-image) produces a generic image, with a STATE partition already present but empty - exactly the same for every node. janusctl image seed-controller only writes controller_address/controller_ca_cert into that partition, without repartitioning, without rewriting the rootfs, and without running any janusd/gRPC call (see internal/diskseed). One local command per node, no server to run.

This is Janus’s equivalent of Talos Image Factory’s “Embedded machine configuration” option (factory.talos.dev) - putting the config directly into the image rather than delivering it separately at boot.

  • The generic image, already built: make proxmox-image (or image/disk/assemble.sh for the raw disk before its qcow2 conversion).
  • janusctl: the Debian package, or make build (then ./bin/janusctl instead of janusctl below).
  • The Controller’s CA certificate (not sensitive, retrievable without credentials):
Terminal window
echo | openssl s_client -connect <CONTROLLER_HOST>:8443 \
-servername <CONTROLLER_HOST> 2>/dev/null \
| openssl x509 -outform PEM > controller-ca.crt
Terminal window
cp build/disk.img my-node.img # or any copy of the generic image
janusctl image seed-controller \
-controller-address <CONTROLLER_HOST>:8443 \
-controller-ca controller-ca.crt \
my-node.img

A second call on the same disk is refused (no silent overwrite) - start again from a fresh copy of the generic image to change Controllers.

For a qcow2 (what make proxmox-image produces)

Section titled “For a qcow2 (what make proxmox-image produces)”

go-diskfs (used by seed-controller) can’t read qcow2 directly - convert to raw, seed, convert back:

Terminal window
qemu-img convert -O raw my-node.qcow2 my-node.img
janusctl image seed-controller \
-controller-address <CONTROLLER_HOST>:8443 \
-controller-ca controller-ca.crt \
my-node.img
qemu-img convert -O qcow2 -c my-node.img my-node.qcow2
rm my-node.img

Transfer/attach my-node.qcow2 (or .img) as usual (qm importdisk, direct upload, etc.), start the VM, then follow the Controller’s logs:

Terminal window
ssh root@<CONTROLLER_HOST> "docker logs janus-controller -f"

Wait for node self-registered: ..., then approve the node in the UI (or through GET /api/pending).

At scale: the same image, seeded once per environment, can be cloned (Proxmox linked clone or equivalent) for as many nodes as needed - seed-controller runs once per environment/Controller, not once per node.

This is the path hack/lifecycle-install-test.sh and a real deployment (Proxmox VM) follow - heavier, but needed when the rootfs itself (not just the Controller config) has to differ per node.

LifecycleService.Install needs a running janusd to serve the RPC - but the node being installed doesn’t exist yet. So a native, temporary janusd runs just for the duration of the call, on any Linux machine with sudo (the build machine is fine) - this native janusd writes straight to a disk file, which is then transferred to the target hypervisor.

  • An up-to-date build: make build (produces bin/janusctl and bin/janusd); add rootfs-build to build your own release bundle (step 1).
  • A Controller (dashboardd) already running and reachable over HTTPS on its registration port (:8443 by default).
  • sudo on the machine running this procedure (for the chroot() of janusd/haproxy).

A release’s own bundle, signed with the Janus release key:

Terminal window
V=<VERSION> # e.g. v2026.10.01-3
mkdir -p /tmp/provision/bundle
for f in rootfs.squashfs rootfs.squashfs.sha256 rootfs.verity uki-a.efi uki-b.efi; do
curl -fsSL -o /tmp/provision/bundle/$f \
https://github.com/swenske/Janus/releases/download/$V/$f
done

Or a bundle built from this tree (make build rootfs-build first):

Terminal window
mkdir -p /tmp/provision/bundle
image/release/assemble.sh /tmp/provision/bundle build/bzImage build/rootfs

Either way, /tmp/provision/bundle holds rootfs.squashfs/rootfs.squashfs.sha256/rootfs.verity/uki-a.efi/ uki-b.efi. A bundle you build yourself isn’t signed: Install refuses it unless step 5 adds -insecure-skip-signature-check (development bundles only).

Terminal window
echo | openssl s_client -connect <CONTROLLER_HOST>:8443 \
-servername <CONTROLLER_HOST> 2>/dev/null \
| openssl x509 -outform PEM > /tmp/provision/controller-ca.crt
Terminal window
truncate -s 2G /tmp/provision/disk.img
Terminal window
sudo ./bin/janusd -addr 127.0.0.1:17500 \
-pki-dir /tmp/provision/native-pki \
-haproxy-binary /nonexistent \
-haproxy-config /tmp/provision/haproxy.cfg \
-haproxy-pid /tmp/provision/haproxy.pid \
-haproxy-stats-socket /tmp/provision/haproxy.sock \
-haproxy-chroot-dir /tmp/provision/haproxy-chroot \
> /tmp/provision/native-janusd.log 2>&1 &

(-haproxy-binary /nonexistent: serving Install doesn’t need a real HAProxy, and failing to start one isn’t fatal.) Wait 2-3 s, then check that the line janusd ... listening on 127.0.0.1:17500 shows up in the log.

Terminal window
sudo ./bin/janusctl \
-ca /tmp/provision/native-pki/ca.crt \
-cert /tmp/provision/native-pki/admin.crt \
-key /tmp/provision/native-pki/admin.key \
-endpoint 127.0.0.1:17500 \
lifecycle install \
-controller-address <CONTROLLER_HOST>:8443 \
-controller-ca /tmp/provision/controller-ca.crt \
/tmp/provision/disk.img /tmp/provision/bundle

Use the Controller’s real hostname here, not its IP - the node writes its own /etc/resolv.conf at boot (see rootfs/init/main.go’s writeResolvConf, fixed on 2026-09-27), so there’s no need to work around it with an IP any more.

-network-config net.json applies a network configuration from the first boot (DHCP otherwise).

It must end with [done 100%] installed to ... (slot A active).

Terminal window
sudo sgdisk -p /tmp/provision/disk.img

No warning expected (in particular no “Secondary partition table overlaps…”). Stop and clean up the native janusd:

Terminal window
sudo pkill -f 'bin/janusd.*native-pki'
sudo rm -rf /tmp/provision/native-pki

7. Transfer the disk to the target hypervisor

Section titled “7. Transfer the disk to the target hypervisor”

Example for an already-created Proxmox LV (vm-<VMID>-disk-N, the same size as disk.img):

Terminal window
ssh root@<PROXMOX_HOST> "dd of=/dev/pve/vm-<VMID>-disk-N bs=4M conv=fsync" \
< /tmp/provision/disk.img

Check it afterwards on the hypervisor:

Terminal window
ssh root@<PROXMOX_HOST> "sgdisk -p /dev/pve/vm-<VMID>-disk-N"

Typical Proxmox config (see also image/kvm-proxmox/README.md): OVMF BIOS, machine: q35, boot: order=virtio0, serial0: socket + vga: serial0 (Proxmox’s console is then the serial port, also reached with qm terminal <VMID>; with the default display instead, the node shows its console on the screen too, through the UEFI framebuffer).

Terminal window
ssh root@<PROXMOX_HOST> "qm start <VMID>"

Follow the Controller’s logs:

Terminal window
ssh root@<CONTROLLER_HOST> "docker logs janus-controller -f"

Wait for the line:

node self-registered: <node-ip> (<node-ip>:9505), awaiting approval

Then approve the node in the Controller’s UI (the Waiting for approval card) - it’s also listed by GET /api/pending if the approval needs scripting.

Terminal window
rm -rf /tmp/provision

How it works: at boot, rootfs/init scans the attached disks - virtio, SCSI/SATA/USB, NVMe, and CD-ROM drives (except its own boot disk) - for a volume (ISO9660 or vfat) labeled cidata/CIDATA - exactly cloud-init’s “NoCloud” convention, the one Talos Linux itself reuses for its own machine config. The content isn’t real cloud-init (#cloud-config, write_files, etc.) - just Janus’s minimal JSON (controller_address/controller_ca_cert), in a user-data file at the root of the volume. Any tool that already generates a cloud-init disk for your other VMs (Terraform libvirt/Proxmox/OpenStack providers, cloud-localds, etc.) can already produce this volume - only the content changes.

The node’s image stays fully generic and shared - nothing is ever written into it for this mechanism, unlike methods 1/2.

Building the volume by hand (example with mtools)

Section titled “Building the volume by hand (example with mtools)”
Terminal window
truncate -s 1M cidata.img
mkfs.vfat -F 12 -n cidata cidata.img
cat > user-data <<EOF
{"controller_address":"<CONTROLLER_HOST>:8443","controller_ca_cert":"$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' controller-ca.crt | sed 's/^"//;s/"$//')"}
EOF
mcopy -i cidata.img user-data ::user-data

Attach cidata.img to the VM as an extra disk (next to the system disk, which stays the unchanged generic image) - or an ISO9660 cidata as a CD-ROM, which is what Proxmox’s cloud-init drive does (xorriso -as mkisofs -V cidata -J -r -o cidata.iso <directory with user-data>) - then boot normally: the node reads the volume, writes controller/address/controller/ca.crt to its own STATE partition, and registers itself.

Whatever the way it was provisioned, a node that can’t reach its Controller keeps trying: 5 s after the first failure, then twice as long each time, at most every two minutes. Each failure is on its console (selfregister: ... - retrying in ...). Images up to v2026.10.03-2 try once a boot only.

Instead of a full local user-data, the volume can hold just a meta-data pointing to a URL (a real cloud-init NoCloud feature, kept as is):

{"seedfrom": "https://example.internal/janus/user-data"}

Three trust modes for that retrieval, picked automatically from what’s provided (see internal/nocloud for the details):

meta-data contains…URLBehavior
seedfrom_ca_cert (PEM)must be https://verified only against that CA (a Janus extension, not standard cloud-init)
nothing elsehttps://verified against the system trust store (standard HTTPS behavior)
nothing elsehttp://no verification, in the clear (PXE-style, like talos.config=)
data "cloudinit_config" "janus_seed" {
gzip = false
base64 = false
part {
content_type = "application/json"
content = jsonencode({
controller_address = "controller.example.com:8443"
controller_ca_cert = file("controller-ca.crt")
})
}
}
# ... attach the result as a cloud-init disk with the provider in use
# (libvirt_cloudinit_disk, proxmox cicustom, etc.) - the content above
# becomes the cidata volume's user-data, whatever the provider.

How it works: the ISO (janus.iso from the releases, or one for a schematic from janus.sw-servers.net) boots a temporary Janus, with no STATE partition, that carries its release’s signed bundle (/etc/janus/release). From your workstation, janusctl lifecycle install has it install the machine’s disk, with the Controller’s address and CA. The installed disk is what announces itself to the Controller, on its first boot - never the ISO.

  • Physical machine: copy the ISO to a USB stick (dd if=janus.iso of=/dev/sdX bs=4M conv=fsync) and boot from it in UEFI mode, Secure Boot off (or with Janus’s certificate enrolled).
  • VM: attach the ISO as a disk (Proxmox: qm importdisk <vmid> janus.iso <storage>, then boot from that disk), with the blank target disk next to it.
  • Not as a CD/DVD (optical drive, a VM’s CD-ROM drive, an iDRAC/iLO “CD” virtual media): the ISO’s root is a partition of its disk image, which an optical drive doesn’t expose. Virtual media that present the image as a removable disk (“removable disk”, “USB key”) work.

The console - serial, and screen - shows this temporary instance’s CA certificate, admin certificate and key once: copy them (ca.crt, admin.crt, admin.key). They’re only used to drive the installation.

Terminal window
CTL="janusctl -endpoint <machine-IP>:9505 -ca ca.crt -cert admin.crt -key admin.key"
$CTL system mounts | grep /etc/janus/release # the ISO's disk (e.g. /dev/sdb1 -> /dev/sdb)
$CTL system info # the disks the kernel sees

The target disk is the other one: /dev/sda, /dev/nvme0n1… Install refuses the disk the ISO booted from, and a disk that already holds a Janus.

Terminal window
$CTL lifecycle install \
-controller-address <CONTROLLER_HOST>:8443 -controller-ca controller-ca.crt \
[-network-config net.json] \
-sha256 "$(curl -sL https://github.com/swenske/Janus/releases/download/<VERSION>/rootfs.squashfs.sha256)" \
/dev/nvme0n1 /etc/janus/release
  • The paths are the machine’s (/dev/nvme0n1, /etc/janus/release), not your workstation’s.
  • -sha256: janusctl can’t read the bundle on the machine to find it itself; without it, this check is skipped. The bundle’s signature is checked either way. For an ISO from the site, it’s the sha256 of rootfs.squashfs listed with the image’s files.
  • -network-config: a network configuration applied from the first boot (DHCP otherwise).
  • The output ends with [done 100%].

Power off, remove the USB stick (or detach the ISO), boot from the installed disk. On its first boot it creates its own PKI - shown once on the console, keep it - and announces itself to the Controller.

The node shows up in the Controller’s Waiting for approval card, with its CA’s fingerprint - the node’s console shows the same one when it announces itself (selfregister: announced ... check it shows this node's CA as SHA-256 ...): compare them, then Approve. A node that sent no key takes the fleet’s trust within 15 seconds. It announces itself only once (a marker on STATE remembers it, and an enrollment waiting for approval survives a reboot), even after a reboot.

Alternative: install without -controller-*, and attach a cidata volume (method 3) to the installed disk’s first boot.

The images boot whatever the disk is called: their UKIs name the partitions by GPT label (PARTLABEL=BOOT-A-DATA…), and the kernel waits for them (dm-mod.waitfor) - virtio (/dev/vda), SATA/SAS/USB (/dev/sda), NVMe (/dev/nvme0n1).

  • Disks: AHCI (SATA), NVMe, USB, virtio-blk and virtio-scsi (Proxmox’s default controller), VMware pvscsi, Broadcom MegaRAID RAID/HBA controllers (Dell PERC), Broadcom/LSI SAS (mpt3sas), Microchip SmartPQI (HPE).
  • Network cards: Intel e1000, e1000e, igb, igc, ixgbe, i40e; Realtek r8169; Broadcom tg3, bnxt; Mellanox ConnectX-4 and later (mlx5); VMware vmxnet3; virtio. No firmware file is shipped: a card that needs one (Intel ice, Broadcom bnx2x…) isn’t supported.
  • Processors: every core (SMP, up to 512), x2APIC, NUMA.
  • Console: the screen (UEFI framebuffer) and the ttyS0 serial port - first-boot credentials included.
  • One Janus installation per machine: two Janus disks would carry the same labels.

Getting a node’s PKI credentials (every method)

Section titled “Getting a node’s PKI credentials (every method)”

Useful for direct janusctl/browser access (per-node view) on top of the Controller. The credentials are on the STATE partition (partition 6 of the disk, ext4) - extracted without mounting, with debugfs:

Terminal window
ssh root@<PROXMOX_HOST> "sgdisk -i 6 /dev/pve/vm-<VMID>-disk-N" \
| grep -E 'First sector|Partition size'
# -> note START_SECTOR and SECTOR_COUNT
ssh root@<PROXMOX_HOST> "
dd if=/dev/pve/vm-<VMID>-disk-N of=/tmp/state.img bs=512 \
skip=<START_SECTOR> count=<SECTOR_COUNT> status=none
debugfs -R 'dump pki/ca.crt /tmp/node-ca.crt' /tmp/state.img
debugfs -R 'dump pki/admin.crt /tmp/node-admin.crt' /tmp/state.img
debugfs -R 'dump pki/admin.key /tmp/node-admin.key' /tmp/state.img
"
scp root@<PROXMOX_HOST>:/tmp/node-{ca.crt,admin.crt,admin.key} /tmp/provision/

Build a .pfx with no password (the same file you import into the browser for the per-node view, or into the Controller’s add-node form):

Terminal window
openssl pkcs12 -export \
-inkey /tmp/provision/node-admin.key \
-in /tmp/provision/node-admin.crt \
-certfile /tmp/provision/node-ca.crt \
-passout pass: \
-out /tmp/provision/node-admin.pfx

Careful: a node’s PKI is generated on its first real boot, not at Install/seed-controller time - an already-extracted .pfx goes stale if the disk is rebuilt or boots “from scratch” again (new PKI); regenerate it from the disk currently booted.

Terminal window
ssh root@<PROXMOX_HOST> "rm -f /tmp/state.img /tmp/node-ca.crt /tmp/node-admin.crt /tmp/node-admin.key"