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 -
Installper 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 -Installwrites 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. Nojanusctlcommand 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.
Method 1: shared image + seed-controller (recommended)
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.
Prerequisites
Section titled “Prerequisites”- The generic image, already built:
make proxmox-image(orimage/disk/assemble.shfor the raw disk before its qcow2 conversion). janusctl: the Debian package, ormake build(then./bin/janusctlinstead ofjanusctlbelow).- The Controller’s CA certificate (not sensitive, retrievable without credentials):
echo | openssl s_client -connect <CONTROLLER_HOST>:8443 \ -servername <CONTROLLER_HOST> 2>/dev/null \ | openssl x509 -outform PEM > controller-ca.crtFor a raw disk
Section titled “For a raw disk”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.imgA 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:
qemu-img convert -O raw my-node.qcow2 my-node.imgjanusctl image seed-controller \ -controller-address <CONTROLLER_HOST>:8443 \ -controller-ca controller-ca.crt \ my-node.imgqemu-img convert -O qcow2 -c my-node.img my-node.qcow2rm my-node.imgDeploy and check
Section titled “Deploy and check”Transfer/attach my-node.qcow2 (or .img) as usual (qm importdisk,
direct upload, etc.), start the VM, then follow the Controller’s logs:
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.
Method 2: Install per node (full path)
Section titled “Method 2: Install per node (full path)”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.
Prerequisites
Section titled “Prerequisites”- An up-to-date build:
make build(producesbin/janusctlandbin/janusd); addrootfs-buildto build your own release bundle (step 1). - A Controller (
dashboardd) already running and reachable over HTTPS on its registration port (:8443by default). sudoon the machine running this procedure (for thechroot()ofjanusd/haproxy).
1. Get a release bundle
Section titled “1. Get a release bundle”A release’s own bundle, signed with the Janus release key:
V=<VERSION> # e.g. v2026.10.01-3mkdir -p /tmp/provision/bundlefor 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/$fdoneOr a bundle built from this tree (make build rootfs-build first):
mkdir -p /tmp/provision/bundleimage/release/assemble.sh /tmp/provision/bundle build/bzImage build/rootfsEither 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).
2. Get the Controller’s CA certificate
Section titled “2. Get the Controller’s CA certificate”echo | openssl s_client -connect <CONTROLLER_HOST>:8443 \ -servername <CONTROLLER_HOST> 2>/dev/null \ | openssl x509 -outform PEM > /tmp/provision/controller-ca.crt3. Prepare a blank target disk
Section titled “3. Prepare a blank target disk”truncate -s 2G /tmp/provision/disk.img4. Start a temporary native janusd
Section titled “4. Start a temporary native janusd”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.
5. Call Install
Section titled “5. Call Install”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/bundleUse 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).
6. Check the disk (sanity check)
Section titled “6. Check the disk (sanity check)”sudo sgdisk -p /tmp/provision/disk.imgNo warning expected (in particular no “Secondary partition table
overlaps…”). Stop and clean up the native janusd:
sudo pkill -f 'bin/janusd.*native-pki'sudo rm -rf /tmp/provision/native-pki7. 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):
ssh root@<PROXMOX_HOST> "dd of=/dev/pve/vm-<VMID>-disk-N bs=4M conv=fsync" \ < /tmp/provision/disk.imgCheck it afterwards on the hypervisor:
ssh root@<PROXMOX_HOST> "sgdisk -p /dev/pve/vm-<VMID>-disk-N"8. Start the VM
Section titled “8. Start the VM”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).
ssh root@<PROXMOX_HOST> "qm start <VMID>"9. Confirm the self-registration
Section titled “9. Confirm the self-registration”Follow the Controller’s logs:
ssh root@<CONTROLLER_HOST> "docker logs janus-controller -f"Wait for the line:
node self-registered: <node-ip> (<node-ip>:9505), awaiting approvalThen 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.
Cleanup
Section titled “Cleanup”rm -rf /tmp/provisionMethod 3: NoCloud volume (cidata)
Section titled “Method 3: NoCloud volume (cidata)”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)”truncate -s 1M cidata.imgmkfs.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/"$//')"}EOFmcopy -i cidata.img user-data ::user-dataAttach 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.
Remote retrieval (seedfrom)
Section titled “Remote retrieval (seedfrom)”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… | URL | Behavior |
|---|---|---|
seedfrom_ca_cert (PEM) | must be https:// | verified only against that CA (a Janus extension, not standard cloud-init) |
| nothing else | https:// | verified against the system trust store (standard HTTPS behavior) |
| nothing else | http:// | no verification, in the clear (PXE-style, like talos.config=) |
Terraform (conceptual example)
Section titled “Terraform (conceptual example)”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.Method 4: the installer ISO
Section titled “Method 4: the installer ISO”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.
1. Boot the ISO
Section titled “1. Boot 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.
2. Find the target disk
Section titled “2. Find the target disk”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 seesThe 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.
3. Install
Section titled “3. Install”$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:janusctlcan’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 ofrootfs.squashfslisted with the image’s files.-network-config: a network configuration applied from the first boot (DHCP otherwise).- The output ends with
[done 100%].
4. Boot the installed disk
Section titled “4. Boot the installed disk”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.
5. Approve
Section titled “5. Approve”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.
Physical hardware
Section titled “Physical hardware”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
ttyS0serial 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:
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):
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.pfxCareful: 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.
ssh root@<PROXMOX_HOST> "rm -f /tmp/state.img /tmp/node-ca.crt /tmp/node-admin.crt /tmp/node-admin.key"