VM Appliance
Build a bootable VM image with Ze baked in using gokrazy. The default target is x86_64, and image.arch in appliance.json selects another architecture for ze appliance build. At runtime, the appliance is minimal: Linux kernel, gokrazy init, and Ze as the only application, with no package manager, no general shell (except authenticated emergency serial console), no unused distro daemons, and automatic process supervision.
Suitable for N100-class mini PCs, Proxmox VMs, or QEMU testing.
What's in the image
| Component | Purpose |
|---|---|
| Linux kernel | Boot and hardware drivers |
| gokrazy init | Starts Ze, supervises it, seeds entropy, sends watchdog heartbeat |
| Ze | BGP daemon with DHCP client and all internal plugins |
| ze-serial-shell | Authenticated emergency shell on serial console (login required) |
Ze owns network configuration in the appliance. The gokrazy default DHCP and
NTP packages are excluded from the image; the shipped Ze seed template enables
interface DHCP auto-discovery with set interface dhcp-auto true and leaves Ze
NTP disabled (set environment ntp enabled false) until the operator enables it
in Ze config.
The root filesystem is read-only (SquashFS). Persistent data lives on a separate ext4 partition mounted at /perm.
Prerequisites
Install once on the build machine.
macOS:
brew install e2fsprogs # ext4 filesystem tools
brew install qemu # VM runtime (testing only)
Linux:
sudo apt-get install -y e2fsprogs qemu-system-x86 # Debian, Ubuntu
sudo dnf install -y e2fsprogs qemu-system-x86 # Fedora
The build needs mkfs.ext4, debugfs, and e2fsck from e2fsprogs. The native
builder resolves each tool independently from the Homebrew keg, standard sbin
directories, and PATH.
For appliance ISO creation, install grub-mkstandalone (or grub2-mkstandalone)
plus xorriso.
ze appliance iso checks those tools before it stages an ISO.
The vendored gokrazy command lives at cmd/ze-gok; the appliance builder calls
it in process. No separate gokrazy installation or first-party script is
required.
First-time setup
Initialize the named appliance before its first build:
ze appliance init edge-01
The build resolves pinned system packages through the Go module graph.
Runtime Kernel Requirements
The runtime kernel is pinned by two manifests, gokrazy/kernel/kernel.require
and gokrazy/kernel/runtime.require, holding 59 symbols between them. Each is
checked against the resolved config after the build, and enforce_required_symbols
accepts =y alone. A Kconfig answer of m therefore fails the BUILD rather than
shipping an appliance where a feature Ze accepts in config cannot work. A module
is unreachable in the QEMU test VM in any case: that VM boots this kernel beside
Alpine's own modules, built for another version.
Each symbol has a producer in Ze rather than a test that wanted it.
| Group | Symbols | Producer in Ze |
|---|---|---|
| Subscriber | PPP, PPPOL2TP, PPPOE, L2TP, L2TP_V3 |
The L2TP LNS, and the PPPoE server and client |
| Firewall | NF_TABLES, NF_TABLES_INET, NF_TABLES_IPV4, NF_TABLES_IPV6, NF_CONNTRACK, NF_NAT, NFT_CT, NFT_NAT, NFT_MASQ, NFT_REDIR, NFT_LIMIT, NFT_LOG, NFT_REJECT |
The nftables backend, and translatePolicy, which returns FamilyInet unconditionally. Without NF_TABLES_INET the kernel answers EOPNOTSUPP, Apply's flush fails, the firewall plugin fails startup, and the daemon exits. internal/plugins/copp/translate.go |
| Tunnels | NET_IPGRE_DEMUX, NET_IPGRE, IPV6_GRE, NET_IPIP, IPV6_TUNNEL, IPV6_SIT, VXLAN |
The nine tunnel kinds ze-iface-conf.yang models. Six symbols plus the GRE demux gate. Measured cost: vmlinuz 16352256 to 16450560 bytes, +0.60% |
| Policy routing | IP_MULTIPLE_TABLES, IPV6_MULTIPLE_TABLES, IP_ROUTE_MULTIPATH |
The policy-routing engine. Without IPV6_MULTIPLE_TABLES the kernel folds every table id into the main table and says nothing |
| Traffic control | NET_SCH_HTB, TBF, FQ_CODEL, HFSC, FQ, SFQ, NETEM, PRIO, INGRESS, NET_CLS_U32, NET_CLS_FW, NET_CLS_MATCHALL, NET_ACT_MIRRED |
Class of service, rate limiting, and mirroring |
| Interfaces and VPN | DUMMY, VETH, MACVLAN, VLAN_8021Q, INET_ESP, INET6_ESP, XFRM_STATISTICS, HUGETLBFS, BPF_SYSCALL, BPF_JIT |
Interface creation, IPsec, VPP hugepages, and the eBPF surfaces |
CONFIG_NET_UDP_TUNNEL, CONFIG_WIREGUARD and CONFIG_TUN are =y in the
config fragment and are not pinned in a manifest.
L2TP Kernel Support
Ze's L2TP LNS path needs kernel PPPoL2TP support in the appliance runtime
kernel: CONFIG_PPP, CONFIG_PPPOL2TP, CONFIG_L2TP, and
CONFIG_L2TP_V3. The shared runtime proof kernel also keeps CONFIG_PPPOE
built in for PPPoE evidence. The pinned upstream gokrazy kernel is not assumed
to provide these options.
Build the repo-local kernel before building an appliance intended to terminate
L2TP subscribers:
ze appliance kernel --target runtime --arch amd64
ze appliance build edge-01
Select the QEMU builder or arm64 explicitly when needed:
ze appliance kernel --target runtime --arch arm64 --builder qemu
ze appliance build edge-arm64
ze appliance kernel calls the Go driver in
internal/appliance/kernelbuilder. The driver reads
internal/appliance/kernel.version, selects Docker or QEMU, resolves the
tracked config fragments and .require manifests, and writes the runtime
kernel tree. The Go worker enforces every required symbol before the artifact
can be used.
On a Linux runner with QEMU, xl2tpd, pppd, /dev/ppp, and PPPoL2TP kernel
support, the deployment proof target builds an L2TP-enabled appliance image and
drives a real LAC against it:
./le deployment gokrazy-l2tp-ppp-test
The proof image is built from a temporary gokrazy instance config so the normal
appliance config is left unchanged. It disables IPv6CP in that proof image
because the current static L2TP pool is IPv4-only. Set
ZE_GOKRAZY_SKIP_BUILD=1 to run against an existing tmp/gokrazy/ze.img that
was already built with the L2TP proof template, the proof runtime environment,
and an L2TP-capable kernel: skip-build bypasses the proof's own kernel
resolution, and an image on the pinned rtr7 kernel (which has no l2tp support)
crash-loops at first boot instead of serving.
Build an image
Create a named appliance once, then build it:
ze appliance init edge-01
ze appliance build edge-01
The named appliance keeps its config and secrets between builds. The build assembles its ZeFS database, builds the disk image through vendored gokrazy, formats the persistent partition, injects the database, and writes the build manifest.
ze appliance show edge-01
ze appliance config edge-01 --merged
Test in QEMU
ze appliance run edge-01
The command boots the named image in QEMU. Quit QEMU with Ctrl-A X.
The Gokrazy management UI is exposed through Ze's authenticated web UI at
/gokrazy/; the proxy reads Gokrazy's password from its standard password-file
locations.
Deploy to hardware
Write the image to a USB drive or internal disk on your N100 machine:
# Linux
sudo dd if=tmp/gokrazy/ze.img of=/dev/sdX bs=4M status=progress
# macOS
sudo dd if=tmp/gokrazy/ze.img of=/dev/rdiskN bs=4m
Or import into Proxmox:
qm importdisk <vmid> tmp/gokrazy/ze.img <storage>
The machine boots to a serial console (115200 baud). Ze starts automatically, gets a DHCP address, and loads its active configuration from /perm/ze/database.zefs (bootstrapped from the seed template on first boot). The serial console requires authentication with the local admin credentials before granting shell access. If the credentials database is missing or unreadable, access is granted without authentication for emergency recovery. When admin-enabled: false is set in the appliance config, the serial console denies the built-in admin (fail-closed) and prints "local admin login disabled".
Configuration
Seed config
The initial Ze config is stored as the seed template in
gokrazy/ze/ze.conf. ze appliance assemble uses it when no base or
per-appliance overlay exists. The seed database holds no active config that can
shadow the template, so the appliance builds its effective config on first
boot.
set environment log level info
set environment web enabled true
set environment web server default ip 0.0.0.0
set environment web server default port 8080
set environment ssh enabled true
set environment ssh server default ip 0.0.0.0
set environment ssh server default port 22
set environment ntp enabled false
set interface dhcp-auto true
To change the seed config, edit gokrazy/ze/ze.conf, or use the structured
workflow's config-base and per-appliance ze.conf files.
Runtime config
Once booted, use ze config edit over SSH to modify the running configuration. Changes are stored in /perm/ze/database.zefs and persist across reboots and image updates.
Environment variables
Ze's environment is set in gokrazy/ze/config.json under PackageConfig:
| Variable | Value | Purpose |
|---|---|---|
ze.config.dir |
/perm/ze |
Persistent storage for database.zefs |
ze.bgp.api.socketpath |
/tmp/ze.socket |
API socket location |
ze.bgp.daemon.drop |
false |
✕ |
ze.log |
info |
Log level |
ze.log.backend |
kmsg,stderr |
Logs go to kmsg and gokrazy ring buffers |
ze.gokrazy.enabled |
true |
Enables appliance auto-init fallback and the. /gokrazy/ |
Kernel crash capture
An appliance that suffers a kernel panic reboots and tells you nothing. Root is read-only SquashFS, the image holds no busybox and no shell, and the kernel that faulted is gone. Crash capture asks the kernel to write its own panic message and backtrace into a reserved memory region before it goes down, so the evidence survives the warm reboot.
Two halves, and both are needed:
| Half | Where | What it does |
|---|---|---|
| The reservation | image.crash-dump.reserve in the appliance config |
Renders reserve_mem=<N>M:4096:zecrash and ramoops.mem_name=zecrash onto the built image's kernel command line |
| The intent | system crash-dump enabled true in the Ze config |
Tells the running daemon to harvest the record at boot and store it as a crash report |
"image": {"arch": "amd64", "size-bytes": 2147483648, "crash-dump": {"reserve": "16mb"}}
The reservation is a boot argument, so it takes effect at the next boot of an
image built with it, never at a config commit. show crashes reports the two
states separately as configured and armed, and ze doctor raises
doctor-crash-capture-unarmed while they disagree.
The reserve is 4mb to 256mb and is taken from RAM on every boot, so it is sized for a backtrace: 16mb is the default and holds a full one. The build refuses a value outside that range, and refuses a size that is not a whole number of megabytes, because the appliance has no shell to diagnose a silently ineffective reservation with.
The region is named rather than addressed. Size-named reservation has been in the
kernel since 6.12 and internal/appliance/kernel.version pins a kernel above
that floor, so no per-machine physical address is needed. The runtime kernel
carries CONFIG_PSTORE and CONFIG_PSTORE_RAM, and the build fails if either
stops resolving to =y: without them the kernel would accept the reservation,
take the RAM, and expose nothing to read the record back from.
Harvested records land in the crash directory, which on an appliance is
/perm/ze/crash. Gokrazy's A/B updates replace the root partition and leave
/perm, so a record survives the update that follows a crash. Records share the
ze.crash.keep retention count with Go panic reports, so a panic loop cannot
fill /perm.
Updating
Gokrazy supports atomic A/B partition updates over the network:
ze appliance push <name>
This pushes the most recent image to the device. The system reboots into the new version. If the update fails mid-flight, the previous root partition is still intact.
For full image rebuilds (when you also want to update the kernel or partition layout), use ze appliance build <name> again and re-flash.
Architecture notes
Internal plugins only
Gokrazy has no shell and no PATH. Ze's external plugin mechanism (which uses /bin/sh -c to fork processes) does not work. All Ze plugins (bgp-rib, bgp-gr, bgp-adj-rib-in, etc.) are compiled into the ze binary as internal plugins and run as goroutines. This is the default and covers all standard BGP functionality.
A config that names an external plugin anyway is reported: ze doctor raises doctor-plugin-shell-missing, naming the absent shell and the plugins that depend on it, and a start that reaches the fork names the shell rather than the plugin.
Process supervision
Gokrazy's init restarts Ze if it exits with a non-zero status (except 125, which means "don't restart"). Ze handles SIGTERM for graceful shutdown. Logs (stdout/stderr) are captured in ring buffers visible through the gokrazy web UI.
Persistent storage
The /perm partition (ext4) survives image updates. Ze stores its database (database.zefs), TLS certificates, and config state there via the ze.config.dir=/perm/ze environment variable.
Repo layout
gokrazy/
.gitignore # excludes *.img
ze/
config.json # gokrazy instance config (what to build, how to start)
builddir/
github.com/ze-software/ze/
go.mod # ze dependency pins + relative replace directive
go.sum
github.com/rtr7/kernel/
go.mod, go.sum # linux kernel version pin
github.com/gokrazy/gokrazy/
go.mod, go.sum # gokrazy init system version pin
cmd/dhcp/ # DHCP client
cmd/ntp/ # NTP client
cmd/heartbeat/ # watchdog heartbeat
cmd/randomd/ # entropy seeder
cmd/ze-serial-shell/ # serial console login gate (replaces serial-busybox)
main.go # gokrazy wrapper: symlink + DontStartOnBoot
_gokrazy/ # renamed busybox extrafiles per arch
cmd/ze-gok/
main.go # vendored gokrazy command wrapper
The gok source is vendored under vendor/github.com/gokrazy/. The small
builddir/ modules pin the system-package versions.
Builds never run from this directory
The tree above is the build input; no build runs inside it. Every image build
first copies gokrazy/ze to a fresh directory under the project tmp/,
including the whole builddir, rewriting each filesystem-path replace to an
absolute path so it still resolves from the new depth. gok is then pointed at the
copy, and the copy is deleted afterwards.
ze appliance build prepares an isolated copy through
resolveBuildParentDir. A build leaves the working tree unchanged, and two
builds in one checkout use separate prepared instances.
Build a verified local runtime kernel before the image when required:
ze appliance kernel --target runtime --arch amd64
ze appliance build edge-01
The kernel replacement is written into the prepared copy only, so nothing in the source tree needs to be reverted.
Build-host command
Appliance build commands (ze appliance) and PXE provisioning
(ze install remote) are registered in the Ze binary. Build it from the
repository root when it is not already installed:
go build -tags ze_setup -o bin/ze ./cmd/ze
Building and installing an appliance (end to end)
From a JSON config (recommended)
Write an appliance config file (arch, kernel profile, credentials, networking):
{
"credentials": { "username": "exa", "admin-enabled": true },
"ssh": { "host": "0.0.0.0", "port": "2222" },
"web": { "enabled": true, "host": "0.0.0.0", "port": "8080" },
"tls": { "cert-name": "router.local", "validity-years": 10 },
"identity": { "hostname": "ze-prod" },
"device": { "address": "10.12.104.10", "update-port": 443 },
"image": { "arch": "amd64", "size-bytes": 2147483648, "kernel-profile": "hardware-kms" }
}
Reserving hugepages for VPP
When the appliance runs VPP, reserve hugepages at boot by adding image.hugepages
to the config. ze appliance build bakes default_hugepagesz/hugepagesz/hugepages
into the boot cmdline (via a derived gokrazy instance config; the checked-in
gokrazy/ze/config.json is never modified). Declare image.memory so the build
rejects a reservation over 50% of target RAM and ze appliance run sizes QEMU's
-m to match. Sizes are byte-size strings (10b, 512mb, 1gb, 1tb;
case-insensitive, 1024-based):
"image": {
"arch": "amd64",
"size-bytes": 2147483648,
"memory": "8gb",
"hugepages": { "size": "1gb", "page-size": "2mb" }
}
hugepages.size is the total reservation and page-size is 2mb or 1gb; the
page count is size / page-size (so size must be a whole multiple of
page-size). The reservation is bounded to 512 GiB and, when memory is set, to
50% of it. 1gb pages need CPU pdpe1gb support and CONFIG_HUGETLBFS in the
kernel profile (both surfaced by ze doctor).
Build the full ISO through the structured appliance actions:
ze appliance init --config prod.json prod
ze appliance kernel prod
ze appliance initrd
ze appliance build prod
ze appliance iso prod
The named appliance retains its config and secrets for subsequent builds.
Manual steps
Use the same commands individually when you need to inspect an intermediate artifact:
# Create the appliance with config and secrets.
ze appliance init --config prod.json prod
# Check and prepare ISO prerequisites.
ze appliance iso --check
ze appliance kernel prod
ze appliance initrd
# Build the disk image and installer ISO.
ze appliance build prod
ze appliance iso prod
# Provision the resulting image over the network when needed.
ze install remote \
--interface eth0 \
--network 10.0.0.0/24 \
--image ~/.config/ze/appliances/prod/ze-*.img \
--ssh-username admin \
--ssh-password 'choose-a-strong-one'
The kernel and initrd commands first check the XDG cache. If
ze.appliance.kernel.url or ze.appliance.initrd.url is set, the matching
command then tries that configured prebuilt-artifact URL; otherwise it builds
locally. Kernel local builds use the shared Docker-or-QEMU builder selection, and
initrd local builds compile and pack cmd/ze-installer. Once cached, subsequent
runs are instant. See "ISO prerequisites" below for details.
ze appliance (structured workflow)
The ze appliance command provides structured appliance management. Each
appliance has its own directory with a JSON config, secrets that are optionally
encrypted at rest, and a TLS certificate.
Quick start
ze appliance init lab # interactive wizard
ze appliance build lab # full image
ze appliance kernel lab # installer kernel
ze appliance initrd # installer initrd
ze appliance iso lab # bootable installer ISO
ze appliance list # show all appliances
ze appliance show lab # config summary and cert expiry
Appliance directory
By default, appliances live in ~/.config/ze/appliances/. Override with --dir or ZE_APPLIANCE_DIR.
~/.config/ze/appliances/
_shared/
ze.conf # optional base config for all appliances
lab/
appliance.json # config (no credentials)
ze.conf # per-device config overrides
secrets/ # 0700 permissions
.encrypted # marker (present = secrets encrypted)
tls/
cert.pem # device certificate, then the appliance CA root (plaintext)
key.pem # private key (encrypted if passphrase set)
ca-cert.pem # appliance CA root certificate (plaintext)
ca-key.pem # appliance CA root key (encrypted if passphrase set)
password.hash # bcrypt hash (encrypted if passphrase set)
update.token # gokrazy OTA token (encrypted if passphrase set)
authorized_keys # SSH public keys (plaintext)
Encryption
Secrets are encrypted at rest with Argon2id + XChaCha20-Poly1305 when an encryption passphrase is set during ze appliance init. The passphrase is never stored on disk. For fleet operations, ze appliance unlock starts a passphrase agent (like ssh-agent) that holds the derived key in memory.
ze appliance unlock # start agent
ze appliance unlock --duration 15m # auto-expire after 15 minutes
ze appliance unlock --stop # stop agent
Day-2 operations
ze appliance passwd lab
ze appliance replace-cert lab
ze appliance replace-cert lab --cert ca.pem --key ca.key
ze appliance rekey lab
ze appliance clone lab lab2
Without --cert and --key, replace-cert issues a new device certificate
from the appliance's own certificate authority, which ze appliance init
generated. The root does not change, so a device you already pushed to stays
reachable: ze appliance push trusts the issuer rather than one certificate.
replace-cert validates the material before it writes anything. It refuses a
certificate and a key that are not a pair. It refuses a file that holds no PEM
data. It refuses a certificate that is past its not-after date, and the message
gives both validity dates. A certificate whose validity starts in the future is
accepted, because a staged renewal is copied into an image that boots later.
--cert and --key must be given together.
A refusal leaves cert.pem and key.pem byte-identical to what the appliance
already held. Both files are written through a temp file and a rename, so an
interrupted run leaves neither file truncated. When the key write fails after
the certificate write, the command puts the previous certificate back and
reports the restore. ze appliance init validates and writes the same way.
A ze older than this validation could store a certificate and a key that do
not load as a pair. On such an appliance the web listener does not start. Run
ze doctor to find it: the stored pair is reported as doctor-tls-invalid,
"certificate and key in storage are not a usable pair". replace-cert fixes it.
Config layering
Set config-base in appliance.json to share a base config across appliances:
{
"config-base": "../_shared/ze.conf"
}
The base config is read first, then per-appliance ze.conf is appended. Later set commands override earlier ones; delete commands remove settings from the base.
Commands reference
| Command | Purpose |
|---|---|
init <name> |
Create appliance with config + secrets (encrypted when a passphrase is set) |
assemble [--keep] <name> |
Build ZeFS database only (auto-deletes; use --keep to retain) |
build <name> |
Full image: assemble + gok + ext4 inject + checksum + manifest |
build --all |
Build all appliances |
kernel [--target] [--arch] [--profile] [--builder] [--version] [<name>] |
Download or build an installer or runtime kernel; with <name>, reads arch/profile from appliance config |
initrd |
Download or build the installer initrd |
iso [--image] [--output] [--kernel] [--initrd] [--target] [--builder] [<name>] |
Bootable installer ISO from an existing image |
iso --check |
Check ISO prerequisites without building |
passwd <name> |
Change SSH password |
replace-cert <name> |
Replace TLS cert (reissue from the appliance CA, or --cert/--key for material from another CA); refuses material that is not a valid pair |
rekey <name> |
Change encryption passphrase |
clone <src> <dst> |
Copy config, not secrets |
list |
List appliances with hostname and arch |
show <name> |
Show config, cert expiry, managed status |
run <name> |
Boot in QEMU with port forwarding |
unlock |
Start passphrase agent |
push [--image] [--testboot] [--no-reboot] <name> |
Push image to device via gokrazy OTA update |
push --all [--parallel N] |
Push to all appliances with device.address |
config <name> --merged |
Show effective config (base + overlay) |
config-push <name> |
Push config to running device via SSH |
config-push --all [--parallel N] |
Push config to all addressed devices |
init --batch <manifest> |
Batch init from JSON manifest |
export <name> |
Export appliance to encrypted archive (.ze.enc) |
export --all |
Export all appliances to single encrypted archive |
import [--force] [--dir <path>] <archive> |
Import appliance from encrypted archive |
ISO prerequisites
The ISO build requires an installer kernel, an initrd, grub-mkstandalone, and
xorriso. Use ze appliance iso --check to see what is ready and what is
missing. The kernel and initrd commands handle downloading or building these
artifacts automatically:
ze appliance iso --check
ze appliance kernel lab
ze appliance kernel --profile hardware lab
ze appliance kernel --builder qemu --arch arm64 lab
ze appliance kernel --target runtime
ze appliance initrd
ze appliance iso lab
ze appliance kernel defaults to the installer target. --target runtime
builds the gokrazy runtime kernel tree and enforces the runtime requirement
floor. The installer target tries the cache, then an optional configured
artifact URL, then the local Go driver. The driver selects Docker when
available and otherwise QEMU; --builder docker and --builder qemu force one
backend. Resolved artifacts are cached under $XDG_CACHE_HOME/ze/.
ze appliance initrd uses the same cache, optional configured URL, then local
build pattern for the initrd artifact. The download URL has no built-in release
server default; set ze.appliance.kernel.url or ze.appliance.initrd.url to use
prebuilt artifacts.
ze doctor includes checks for kernel, initrd, grub, xorriso, and e2fsprogs
availability, reporting warnings with actionable hints when prerequisites are
missing.
ISO installer media
Create an installer ISO from an image already produced by ze appliance build. By default the command selects the latest ze-*.img in the appliance
directory, verifies its .sha256 sidecar, and writes ze-*.iso next to the
image. Use --image to select a specific image filename and --output to write
the ISO elsewhere. The output path must not overwrite the selected .img, and
the image filename must stay within [A-Za-z0-9._-] so the initrd can pass it
on the kernel command line. By default, ze appliance iso resolves a matching
installer kernel from the cache, or from build/kernel/Image only when its
variant metadata matches the appliance architecture, profile, and version.
Pass --kernel to select a specific installer kernel.
The kernel cache key includes target, architecture, profile, config, and version. Repeating the same request reuses the verified artifact; a different request gets a separate cache entry.
ze appliance build lab
ze appliance kernel --builder docker --profile hardware lab
ze appliance iso lab
ze appliance iso --image ze-20260601-120000.img lab
ze appliance iso --output /path/to/lab.iso lab
ze appliance iso --kernel build/kernel/Image lab
The ISO is an installer envelope around the existing raw gokrazy image. The image
is gzip-compressed inside the ISO to reduce media size (a 2 GiB image with ~100
MiB of content compresses to roughly 100 MiB). The installer initrd decompresses
the image during installation. The ISO does not rebuild the appliance, regenerate
credentials, fetch a separate ZeFS database, or mutate /perm after writing the
disk image. The installed disk receives the selected image bytes, including the
/perm/ze/database.zefs and /perm/ze/build.json manifest that build already
injected.
The ISO boot path accepts an optional explicit target disk. If no target is set,
the installer writes only when exactly one non-removable candidate disk remains
after excluding the ISO source media. The initrd also matches the booted ISO by
a builder-generated ze.media-id token before it trusts a mounted installer
volume, so identical image filenames on multiple attached installer media do not
confuse the source selection. With multiple fixed disks, pass a whole disk path
such as /dev/vda at ISO creation time:
ze appliance iso --target /dev/vda lab
After the installer writes the disk in ISO mode, it powers off instead of rebooting. Remove the installer media, then power the target back on so the firmware boots from the written disk.
The ISO contains the full provisioned appliance image, including the embedded
ZeFS database. Handle the ISO with the same care as the .img file.
USB write method: the ISO can be written with dd, Etcher, or Rufus in DD
mode. Ventoy is also supported when the installer kernel includes loop device
and FAT/exFAT filesystem support (the hardware kernel profile has this). The
initrd detects the ISO file on the Ventoy data partition, loop-mounts it, and
proceeds with the installation. When using the qemu kernel profile, Ventoy
is not supported.
Remote operations (push, config-push)
Push a built image to a running gokrazy device via its HTTPS update endpoint:
ze appliance push lab
ze appliance push --image ze-20260427-143022.img lab
ze appliance push --all
ze appliance push --all --parallel 4
Push uses the update token (from secrets/update.token) for HTTP basic auth, and verifies the device TLS certificate against the stored cert.pem. That file holds the device certificate and the appliance CA root that issued it, so the anchor is the ISSUER: a certificate reissued with ze appliance replace-cert still verifies, and the device does not have to be pushed again first. No system CA pool is consulted.
When --image is set, the file name must resolve to a regular file inside the
appliance directory. Path traversal and symlinks escaping that directory are
rejected after the local update token is read, but before TLS setup or any
network request starts.
Preview the effective configuration (base + overlay merged) without building:
ze appliance config lab --merged
Push a config change to a running device without rebuilding the image:
ze appliance config-push lab
ze appliance config-push --dry-run lab
ze appliance config-push --all
ze appliance config-push --all --parallel 4
Config-push uses SSH (operator's key via ssh-agent) to upload the merged config to the device, which validates and applies it. No secrets are transmitted over SSH.
Device-side config behavior
At boot, unmanaged devices resolve the active config in ZeFS. If no active
config exists, Ze bootstraps one from the seed template or interface discovery.
If /perm/ze/config-pushed.conf exists and parses as Ze config, Ze writes it
over the active config. If the pushed config fails validation, Ze deletes that
pushed file and continues with the existing active config.
| Stage | Source | Location |
|---|---|---|
| 1 | Existing or bootstrapped active config | file/active/ze.conf |
| 2 | Seed template, only when active config is missing | file/template/ze.conf |
| 3 | Valid pushed config, applied over active config | /perm/ze/config-pushed.conf |
After loading the effective config, the device writes its SHA-256 hash to
/perm/ze/config-active-hash for fleet drift detection.
Last-known-good hash: at build time, ze appliance build writes the SHA-256
of the assembled seed config to meta/config/last-known-good in ZeFS. This
serves as the build-time integrity baseline.
Config push and health monitor: config-push connects over SSH, stages the
merged config, validates it, and applies it on the device. The source-backed
health monitor is armed when a pushed config file is consumed at boot; it watches
BGP peer close events for 30 seconds. If a peer closes during that window, the
device reverts to the previous config, or to the seed config if no previous
config was saved. If the window completes, the active config hash is written to
/perm/ze/last-known-good-pushed.
Batch init
Initialize multiple appliances from a JSON manifest:
ze appliance init --batch manifest.json
Manifest format (array of entries):
[
{"name": "edge-01", "hostname": "edge-01.lab", "password": "secret1", "device.address": "10.0.0.1"},
{"name": "edge-02", "hostname": "edge-02.lab", "password": "generate"}
]
Use "password": "generate" for per-device random passwords (printed to stdout once, never stored in plaintext). When an encryption passphrase is set, each encrypted secret write receives a fresh random salt and nonce.
Disaster recovery (export/import)
Export creates an encrypted archive of an appliance directory for offsite backup or bastion migration. Archives include config, secrets, and build metadata, but exclude images and ZeFS databases (both are rebuildable).
Export a single appliance:
ze appliance export lab
# creates lab.ze.enc in the current directory
Export all appliances:
ze appliance export --all
# creates appliances-YYYYMMDD-HHMMSS.ze.enc
Import restores from an archive:
ze appliance import lab.ze.enc
Import to a different bastion (migration):
ze appliance import lab.ze.enc --dir /path/to/new/bastion
Archives are always encrypted using the same Argon2id + XChaCha20-Poly1305 scheme as secrets at rest. The archive passphrase can differ when provided separately from the secrets passphrase. import --force overwrites files present in the archive, but it does not delete extra files already present in existing appliance directories.