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

How an image is built

A Janus image is built from source, LFS-style, by an ordinary toolchain - Docker, Go, the kernel’s own build - none of which ships in the image: the node only ever receives built bytes. Every upstream is pinned (versions.mk, base images by digest), and no step needs root or a loop device, so the self-hosted runners build it unprivileged.

flowchart TB
    accTitle: The pipeline that builds a Janus image
    accDescr: The kernel, HAProxy with AWS-LC, the Go binaries, the SELinux policy and the extensions are built in Docker from pinned sources. They are layered into a root tree, which becomes a squashfs with its dm-verity hash tree. The kernel and a command line carrying that tree's root hash become a signed unified kernel image. The disk image takes both slots, the EFI partition and STATE, and is converted to qcow2, VMDK, the installer ISO and SD card images; the root filesystem, its hash tree and the kernel images form the update bundle.
    subgraph sources [Built in Docker, pinned]
        kernel["Linux kernel - janus_defconfig"]
        haproxy["HAProxy - static, musl, AWS-LC"]
        gobin["janusd, init, janus-acme - static Go"]
        policy[SELinux policy]
        ext["Extensions - keepalived, BIRD, nftables..."]
    end
    gobin --> tree[root tree]
    haproxy --> tree
    policy --> tree
    ext -->|"a schematic's choice"| tree
    tree --> squash["rootfs.squashfs + rootfs.verity - dm-verity"]
    kernel --> uki["UKI: systemd-stub + kernel + command line with the root hash - signed"]
    squash -->|root hash| uki
    squash --> disk["disk image: ESP, slot A, slot B, STATE"]
    uki --> disk
    disk --> fmt["janus.qcow2, janus-kvm.qcow2, janus.vmdk, janus.iso, Pi SD images"]
    squash --> bundle["update bundle: rootfs.squashfs, .sha256, rootfs.verity, uki-a.efi, uki-b.efi"]
    uki --> bundle
PieceBuilt byNotes
The kernelmake kernel-build (kernel/)kernel/configs/janus_defconfig, made with make kernel-menuconfig, never hand-edited: KSPP hardening, SELinux, dm-verity, nf_tables, the drivers bare metal needs - no firmware files. kernel/built-files-*.txt list what the build reads, for the vulnerability checks
HAProxymake haproxy-build (pkgs/haproxy)Static, against musl and AWS-LC built from source, with its Prometheus exporter; no PCRE2, no Lua
janusd, init, janus-acmemake build, staticallyNo C library anywhere in the base system: Go is static
The SELinux policymake selinux-policy (selinux/)Monolithic, hand-written: a domain per daemon, every rule from a real denial
Extensionsmake extensions-amd64 (extensions/<name>/)A manifest and a Dockerfile each, packed into a tar layered onto the root tree - never replacing a file
The root filesystemrootfs/assemble.shsquashfs and its dm-verity hash tree (veritysetup format). The SELinux labels of the executables and /var/empty’s mode go in as mksquashfs pseudo-files: no root needed
The kernel image (UKI)image/uki/assemble.shukify, with systemd’s stub from a pinned Debian - never the build host’s. The command line: the dm-verity table with its root hash, enforcing=1, the consoles, janus.schematic=. Signed when the release key is given
The diskimage/disk/assemble.shsgdisk, mtools, dd seek=, debugfs - no mount, no loop device. Both slots, the ESP with both UKIs, an empty STATE
The formatsimage/{kvm-proxmox,kvm,vmware,iso,rpi-uefi}qemu-img for qcow2 and VMDK; xorriso for the hybrid installer ISO, with the release bundle inside
The update bundleimage/release/assemble.shExactly these names - rootfs.squashfs, .sha256, rootfs.verity, uki-a.efi, uki-b.efi - which an upgrade fetches under a base URL

The release key signs the UKIs of the release bundle - and the bundle is built only once, in the publishing job, since mksquashfs isn’t byte-reproducible: another build’s UKIs would carry another root hash. The private key lives only in a CI secret, used on the publishing runner and verified (sbverify) against the committed certificate, image/secureboot/production-cert.pem, before anything is published; janusd carries a copy of that certificate to check every update. A schematic’s images are signed the same way, on the same runner.

Every release also publishes what its images are made of - the kernel, the base root tree, each extension’s tar and the catalog of extensions - so an image with extensions is assembled from those, never compiled again: rootfs/assemble-from-base.sh layers the extensions onto the base tree, then the same squashfs, UKI and disk steps. The image factory runs it on demand (schematic-build.yml), and image/schematic/build.sh does it locally (images and extensions).

image-build.yml, on the self-hosted runners, dispatched by hand:

  • The boot chain: QEMU with OVMF, SELinux enforcing - dm-verity, A/B, Secure Boot, the ISO, PXE, bare-metal hardware.
  • Lifecycle: install, upgrades by every path, rollbacks, the automatic revert.
  • Features: the API, the network, the firewall, VRRP, BGP, Let’s Encrypt, Consul, metrics, packet captures, the Controller, the hypervisors, Terraform.
  • Then publish: the images as workflow artifacts, the Controller’s image to Docker Hub, and - for a release - the GitHub release, its signed bundle, janusctl packages, the Terraform provider, the security notes and the SBOM.

Every test, and how to run one: testing.