Servers and dedicated VMs
One isb serve can be the control plane for others. Each other host is a
server: it runs incus and isb serve --agent, and the control plane places
whole orgs on it. Users, agents and the web UI only ever talk to the control
plane; it forwards each call for an org to the server that holds the org.
Use it to spread orgs over machines, to give a customer a host of their own,
or, with a dedicated VM, to give an org its own kernel on the same host.
Why you would pick each is in Placement.
This is federation, not incus clustering. Every server is a complete isb for the orgs on it (the stack controller, ingress, registry, builds, secrets, history), so a server keeps running its orgs' workloads when the control plane is away, and an org never spans servers.
users, agents, web UI, CLI
|
| sessions, API tokens, Access (authn, authz, audit)
v
+-----------------------------------------------+
| control plane: isb serve |
| identity store, web UI, audit log, events |
| orgs placed "local" run here |
| servers/: CA, servers.json, placement.json |
+-----------------------------------------------+
| mTLS (client cert from its CA) ^
| POST /orgs/<org>/api/v1/tools/<tool> | heartbeat every 10 s
| Authorization: IsbAssert <caller> | events (long poll), mirrored
v |
+--------------------------+ +--------------------------+
| server "hel-1" | | server "nbg-2" |
| isb serve --agent :7443 | | isb serve --agent :7443 |
| orgs: acme, beta | | orgs: gamma |
| incus, its own age key, | | ... |
| controller, ingress, | | |
| registry, builds | | |
+--------------------------+ +--------------------------+
Adding a server#
isb server add hel-1 --ssh root@203.0.113.7 --key ./bootstrap_ed25519 \
--allow-from 198.51.100.4 # the control plane's egress address
isb server ls
isb server show hel-1
isb server add (the server_add tool; platform admins) needs a fresh Linux
box (Ubuntu or Debian, x86_64 or aarch64) reachable over SSH as root or as a
user with passwordless sudo. Over SSH it:
- checks the box's architecture (and, for a user other than root,
passwordless sudo) and uploads the isb binary:
--isb-binary(a Linux build, checked to be one for the box's architecture),--self-binary(the daemon's own executable: the same build as the control plane), or by default this version's release, checked against the release'sSHA256SUMS. The box checks the upload's SHA-256 again before installing it at/usr/local/bin/isb; - creates the system user
isb(home/var/lib/isb), gives the incus containers their uid range, installs incus from Zabbly's stable channel (asisb machinedoes in its VM) and initialises it (incus admin init --auto) if it has no storage pool; - with
--allow-from, enables ufw: SSH, and the agent port from those addresses only. Without it the port is open to any address (the agent still admits only the control plane's client certificate) and the command says so; - runs
isb host setup --user isb(org bridges, DHCP, DNS, service names); - writes the agent's TLS material to
/etc/isb-agent/(owned byisb, 0700; key 0600) and the unitisb-agent.service, which runsisb serve --agent --agent-listen 0.0.0.0:7443 --agent-tls /etc/isb-agent --state-dir /var/lib/isb/state, and starts it, with the upgrade helper (Upgrading servers); - waits for the agent's heartbeat over mTLS and checks it presents the certificate just issued.
Every step is idempotent: rerunning isb server add after a failure
converges. The CLI prints each step and the box's output as they happen:
it calls server_add with wait: false, which answers at once, and
follows server_provision_get (steps with their state and times, the log,
the error). The web UI's Add server wizard (Platform, Servers) does the
same, and offers Retry on a failure. A run's progress stays readable for an
hour after it fails (and ten minutes after it succeeds), in memory only; one
run per server name at a time. Without wait: false, server_add answers
when the server is up.
The SSH key is used for this and never again; the control plane
keeps only user@host, the box's host key (servers/known_hosts) and what
it dials. SSH runs with exactly that key (-F /dev/null, IdentitiesOnly,
no agent). Through the API, key (a path) is for the local CLI only; a remote
platform admin sends ssh_key, the key itself, which is written 0600 for the
bootstrap and deleted after. The web wizard sends it once and clears it from
the form.
| Flag | Default | |
|---|---|---|
--ssh user@host |
how to reach the box | |
--port |
22 | SSH port |
--key FILE |
the SSH private key, bootstrap only | |
--address |
the SSH host | what the control plane dials; the agent certificate's SAN |
--agent-port |
7443 | the agent's mTLS port |
--allow-from CIDR |
none | who the box's firewall lets reach the agent port (repeatable) |
--isb-binary FILE |
this version's release | the Linux binary to install |
--isb-version V |
this version | the release to install |
--self-binary |
off | install the daemon's own executable instead (self_binary) |
--public-ingress |
off | serve the server's orgs' domains on its own 80 and 443 (isb host setup --public-ingress, the agent's --ingress-http/--ingress-https) |
server_list also suggests the addresses this control plane's traffic leaves
from (suggested_allow_from), which the wizard prefills.
isb server rm NAME forgets a server; it is refused while orgs are placed on
it. The agent keeps running on the box until it is stopped there
(systemctl disable --now isb-agent); a dedicated VM is
deleted with its server. isb server rotate-cert NAME (or Rotate certificate on the server in
the web UI's Admin, Servers) issues the
agent a new certificate and key over the current mTLS connection; the agent
writes them and uses them for every new connection, and the control plane
checks that it does.
Placing an org#
isb org create acme --server hel-1 [--cpus 4] [--memory 8GiB] [--allow-egress ...]
isb org create beta --vm [--vm-cpus 4 --vm-memory 8GiB --vm-disk 100GiB]
(org_create with placement: {"server": "hel-1"}, or server: "hel-1";
platform admins.) The control plane tells the agent the org is placed on it,
creates it there (its incus project, bridge and ACL), and adds it to its own
identity store for members, invitations and tokens. The default org is always
local. Bind roots, the domain allowlist and the ingress provider of an org on
a server cannot be set through the control plane. An org does not move once
placed (Moving an org).
From then on every org-scoped call for that org goes to its server, on every
surface: MCP (/mcp and /orgs/<org>/mcp), REST, the CLI over the control
plane's socket, the web terminal's and SSH websockets (bridged
through to the agent's; SSH to an org on a server)
and app webhooks (forwarded as they came; the agent checks the signature,
since it holds the app's secret).
The control plane authenticates the caller, runs its authorizer (roles, token
scopes, org scope) and writes its audit row first; the agent then judges the
asserted caller again, in the org the call was sent for:
- the call goes to
/orgs/<org>/...on the agent, which pinsorg, so a forgedorgin the arguments is refused; - the agent refuses any org not placed on it, even for a platform admin;
- the asserted principal's role and scopes apply as they would on the control plane.
A deploy from the control plane's CLI (isb stack deploy --org acme) sends
the resolved compose file as YAML; host paths of the control plane mean
nothing on the server, so its binds are held to the server's own rules
(no bind roots by default).
Cross-org reads (overview, stack_list, ingress_status, org_list)
run on the control plane and on each server holding an org the caller can
see, in parallel (15 s each), and merge: every item from a server carries
"server": NAME, and a server that did not answer is listed under
unreachable instead of failing the call. Events from servers are
mirrored into the control plane's feed (a long poll per server, only events of
orgs placed on that server), so events, GET /api/v1/events and the web UI
show them with the control plane's own sequence numbers. A server's feed is
followed from where it is when the control plane starts; what happened while
the control plane was down stays in the server's own history.
Stays on the control plane whatever the org: events, audit_list and
audit_verify (the audit log is the control plane's; agents keep their own
too), template catalogs, registry_gc, notification_settings,
server_status and the server_* tools.
For an org on a server, the server's agent also runs the org's
workspace, keeps its token and serves the bridge
listener on that server's bridge (port 8481; isb host setup, run by the
bootstrap, opens it to the org bridges in the box's firewall); the control
plane forwards the workspace_* tools like any org call. The bridge
listener there takes the workspace's own token only: org API tokens live in
the control plane's identity store, so a workspace reaches isb through its
token, and people and agents outside reach it through the control plane.
The agent reaps the org's expired and idle sandboxes; the control plane's
reaper leaves them alone.
Volume snapshots, backups and staged restores run on the agent too, with the org's backup destinations and their keys (org secrets) kept there. The destination must be reachable from the server: an S3 store on the control plane's loopback is not.
SSH to an org on a server#
isb ssh-proxy (and so ssh, scp, editors and herdr) works for instances
of an org on a server exactly as for a local one (SSH). The
control plane admits the websocket, writes ssh.open, reads the caller's
SSH public keys from its identity store at that moment, and opens the same
websocket on the agent with the caller asserted and the keys in a header
(X-Isb-Ssh-Keys, base64url JSON). The agent checks the org as for any
forwarded call, parses each key again (options refused), writes them for
sshd -i in the instance as it does locally, and tells the control plane
which key sshd accepted (a text frame the client never sees).
The grant is checked on the control plane, where the account lives: every
15 seconds, as for a local session (key removed, account disabled, token
revoked or expired, sign-in ended, membership dropped or demoted to
viewer). A failed check ends the bridge with the reason, and closing the
bridge ends the agent's sshd. Both sides keep audit rows: the control
plane's ssh.open and ssh.close (guest user, key fingerprint, duration),
and the agent's own. An agent older than protocol 2 (below) refuses SSH:
"server NAME runs isb ... (agent protocol 1); this needs protocol 2:
upgrade it with isb server upgrade NAME".
Upgrading servers#
isb server upgrade hel-1 # to this control plane's own build
isb server upgrade --all
isb server upgrade hel-1 --isb-version 1.0.1 # a release, checked against its SHA256SUMS
isb server upgrade hel-1 --isb-binary ./isb # a Linux build on this host
server_upgrade (name or all: true, version, isb_binary for the
local CLI only; platform admins) replaces a server's agent and waits until it
answers with the new build:
- It reads the agent's heartbeat (it must answer) for its architecture and
the build it runs (
build: the SHA-256 of its binary), and picks the binary: by default the control plane's own executable, checked to be a Linux build for that architecture. The same build already running is left alone. - It hands the binary over. A server added over SSH gets it over the
agent's mTLS connection, in 3 MiB chunks (
/internal/v1/upgrade/chunk), then/internal/v1/upgrade/applywith its SHA-256: the agent checks size, hash and architecture and stages it as<state>/upgrade/isb.new. A dedicated VM gets it through the incus API (file push, then exec), as at creation, which also installs the helper below on a VM made before it existed. - On the box, the root helper (
isb-agent-upgrade.pathwatches for the staged request and startsisb-agent-upgrade.service, which runs/usr/local/lib/isb/agent-upgrade) copies the binary out of the agent's directory, checks the SHA-256 again on its copy, runsisb --versionwith it as theisbuser, keeps the old binary as/usr/local/bin/isb.prev, replaces/usr/local/bin/isbatomically and restartsisb-agent. - The control plane waits (up to 100 s) for the heartbeat with the new
build, then confirms it (
/internal/v1/upgrade/confirm). Without that confirmation within 120 s of the restart, the helper putsisb.prevback and restarts the agent again; the control plane waits to see that and says so ("... the server restored its previous binary and answers again").
Each takes seconds plus the agent's restart; calls for the server's orgs
fail meanwhile ("reach server NAME") and its workloads keep running. The
helper's last run is in the heartbeat (upgrade.last: restarting,
done, failed, rolled_back) and on the Servers page.
Why this way: the bootstrap's SSH key is used once and never kept, and
keeping one would make the control plane hold a standing root credential for
every box. The mTLS connection already carries every call the control plane
makes; only the control plane's client certificate opens it, and the upgrade
routes take only the control plane's own assertion. The agent's user is in
incus-admin, which is root-equivalent on the box, so a control plane that
can drive the agent could already act as root there; replacing the binary
gives it nothing new. What the upgrade adds is integrity and a way back: the
hash is checked by the agent and again by root on its own copy, the binary
must run before it is installed, and the box (not the control plane, which
cannot reach an agent that did not come back) restores the previous binary.
Version skew#
server_list and server_show answer version: the agent's isb,
build and protocol, the control plane's, skew (another version or
build), compatible, ssh (SSH forwarding needs protocol 2),
upgradable (a dedicated VM, or a server with the helper) and
last_upgrade. isb server ls marks a different build (differs); the
Servers page shows a badge and, in the server's details, its build next to
the control plane's with an Upgrade button.
The agent protocol is how the two sides talk (1: the first; 2: SSH
forwarding, the upgrade routes, and protocol, build and arch in the
heartbeat). The control plane forwards calls to agents speaking protocol 1
or 2 and refuses a newer one with "server NAME runs isb ... (agent protocol
N), newer than this control plane understands (protocol 2): upgrade the
control plane". A server added before the helper existed (by an isb without
server_upgrade) has no upgrade routes: replace its binary by hand once
(Upgrading isb).
mTLS#
The control plane has its own CA under <state>/servers/pki/ (ca.key 0600
in a 0700 directory; ten years), apart from the local registry's. It issues:
- a server certificate per agent, extended key usage serverAuth only, SAN = the address the control plane dials, 397 days;
- one client certificate for itself (
CN=isb-control-plane), clientAuth only.
The agent trusts that CA alone and requires a client certificate with the clientAuth usage, so nothing the control plane did not issue gets a TLS session, and no agent's server certificate can call another agent. The control plane trusts the same CA for the agent and checks the agent's address against the certificate. Both sides use rustls (TLS 1.2/1.3, ring).
The agent's listener is not an API for people: no sessions, no tokens, no web
UI. Each request carries the control plane's assertion of the caller in
Authorization: IsbAssert <base64url JSON> (email, how they signed in, the
token's scopes and org, their orgs and roles, platform admin). The agent
believes it only because the connection is the control plane's.
isb serve --agent is that agent: no identity store, web UI or --listen,
only the mTLS listener (--agent-listen, any address: the client
certificate is the gate) with its TLS directory (--agent-tls: ca.crt,
tls.crt, tls.key).
Dedicated VMs#
For an org that should share a kernel with nobody, the control plane makes it a VM of its own on its own host and runs the org there:
isb org create acme --vm --vm-cpus 4 --vm-memory 8GiB --vm-disk 100GiB
isb org rm acme --delete-vm
org_create with placement: {"vm": {"cpus": 4, "memory": "8GiB", "disk": "100GiB"}} (each optional: 2 CPUs, 4GiB, 40GiB; at least 2GiB of memory
and 10GiB of disk), and wait: false to answer at once and follow
server_provision_get with name vm-acme, as the CLI and the web UI's New
org dialog do. The org's own settings (quota, egress) go with it as for any
placement and are checked before the VM is made. It takes minutes.
A dedicated VM is a server the control plane provisioned itself, through incus rather than SSH:
- checks the host can run VMs: incus lists the
qemudriver and/dev/kvmexists. A host without (a cloud VM without nested virtualization, such as Hetzner's cx line) refuses with the reason, and the web UI shows the option disabled with it (server_listanswersdedicated_vm: {supported, reason}); - creates the VM
vm-acmein theisb-systemproject (made if missing, asisb registry setupmakes it) fromimages:ubuntu/24.04, withlimits.cpu,limits.memoryand a root disk of that size, on the host's managed bridge (incusbr0, never an org's), labelleduser.isb.dedicated-vm=acmeanduser.isb.server=vm-acme, and starts it; - waits for its guest agent and its address, then reserves that address
on the NIC (
ipv4.address), since the control plane dials it and the agent's certificate names it; - copies the control plane's own isb executable into it (file push) and
runs the same root script
server addruns (exec, the script on stdin, so the agent's key is never written to the guest's disk but as its TLS file): incus from Zabbly,isb registry setup,isb host setup, the agent's TLS material andisb-agent.service, and ufw enabled with only the agent port open, and only to the host's address on that bridge. No SSH is installed or opened; - waits for the heartbeat over mTLS, checks the certificate, records server
vm-acme(with avmblock: org, project, instance, size), and creates the org on it as for any server.
Every step is idempotent: run the same command (or press Retry) after a
failure and it reuses the VM and the record. Deleting the org with
delete_vm (--delete-vm, a checkbox in the web UI, on by default there)
deletes the VM and forgets the server once the org is gone; without it the
VM keeps running as an empty server, and isb server rm vm-acme deletes it
later.
What it costs and what it means:
- The VM's CPUs, memory and disk are its own incus limits on the host. They count against nothing else: not the org's quota (which applies inside the VM), not another org's, and isb keeps no host-wide budget. Size it so the host can hold it.
- The agent port is reachable from the host only: the VM's firewall drops
everything else, and the agent takes only the control plane's client
certificate. Other instances on
incusbr0can reach the VM's address, not its agent. - The org's domains: the VM's agent runs no public listeners, so an org in
a dedicated VM serves its domains through its own Cloudflare Tunnel
(
--ingress cloudflare-tunnel, outbound only; Domains). Its published ports and the load balancer are the VM's own, reached from inside it. - Its isb is the control plane's build, copied at creation;
isb server upgrade vm-ORGbrings it to the control plane's current build (Upgrading servers).
Secrets#
An org's secrets live on the server that runs its consumers, encrypted to
that agent's own age key (/var/lib/isb/.config/isb/age.txt on the box,
made at the agent's first start). secret_set and secret_get through the
control plane carry the value in transit (TLS both legs) and nothing of it is
written on the control plane. Add a break-glass recipient on each server
(isb secret reencrypt there) as on any daemon (Secrets).
What each side keeps#
Control plane (<state>/) |
Agent (/var/lib/isb/, /etc/isb-agent/) |
|---|---|
servers/pki/: the CA and its client certificate |
ca.crt, tls.crt, tls.key (0600) |
servers/servers.json: name, address, port, user@host, certificate fingerprint and expiry, isb version, firewall sources, and for a dedicated VM its org, project, instance and size |
state/agent/orgs.json: the orgs placed on it |
state/upgrade/: an upgrade being staged and the helper's last result; /usr/local/bin/isb.prev, the binary before the last upgrade; the helper (/usr/local/lib/isb/agent-upgrade, isb-agent-upgrade.path and .service) |
|
servers/placement.json: org to server |
everything about those orgs: stacks, apps, deployments, secrets (its own age key), builds, registry, metrics, notifications, jobs, backups, its own audit log |
servers/known_hosts: the boxes' SSH host keys |
|
| the identity store (members, invitations, tokens of every org) and the audit log of every call | no users: an internal identity file only for its org bookkeeping |
The control plane stores no secret value, stack definition or workload state
of an org on a server: only where it is. Back up its servers/ directory
with the rest of its state: the CA is what every agent trusts.
Health#
Every 10 s the control plane asks each agent for its heartbeat
(GET /internal/v1/heartbeat): isb and incus versions, the agent's build,
architecture and protocol, its upgrade helper, CPU, load, memory,
incus storage, the orgs placed there, the number of stacks, and the agent's
last error event. isb server ls and server_show show it with the state:
unknown (not heard from yet), up, unreachable. The web UI's Servers
page shows the same, with how long since the last heartbeat.
Three misses in a row (30 s) make a server unreachable: an event of kind
server.unreachable (level error) on stack <org>/@servers for each org on
it and on system/@servers, with the server as the service. The first
answer after that is server.recovered. Org members see these in their
event feed; notification channels of an org on a server live on that
server, so they hear about it only once it is back.
Failure modes#
| What | Then |
|---|---|
| A server is down or cut off | Its orgs' calls fail with "reach server NAME (connect)" and their workloads are wherever the server left them; other orgs are untouched; server.unreachable after 30 s. Merged reads list it under unreachable. |
| The control plane is down | Servers keep running their orgs (they reconcile, restart, serve their ingress, run jobs and backups); nobody can change anything, since every caller comes through the control plane. |
| The agent restarts | Its event feed starts over; the control plane notices and follows from the start. |
| The control plane's state directory is lost | The CA goes with it: restore it from backup (it is what every agent trusts). Without a backup, re-run isb server add on each box (it reissues the agent's certificate under a new CA) and recreate the placement. |
| A certificate nears expiry | isb server rotate-cert NAME, or Rotate certificate in Admin, Servers; server_show has cert_not_after. |
| An upgraded agent does not come back | The helper on the box restores the previous binary 120 s after the restart; server_upgrade fails saying so, and upgrade.last is rolled_back. |
| An agent speaks another protocol | Calls for its orgs are refused with the reason: upgrade the older side. |
| The SSH key leaks | It was used for the bootstrap only: remove it from the box's authorized_keys; the control plane never needs it again. |
Tools#
| Tool | Does |
|---|---|
server_add |
Bootstrap a server over SSH (wait: false to answer at once). |
server_list |
Servers with their health and orgs, servers being added, whether this host can run dedicated VMs, and suggested firewall sources. |
server_show |
One server. |
server_remove |
Forget one (refused while it holds orgs; a dedicated VM is deleted with it). |
server_rotate_cert |
Issue its agent a new certificate. |
server_upgrade |
Replace a server's agent (or every server's) with this control plane's build or a release, and wait for it (Upgrading servers). |
server_provision_get |
Follow a server (or dedicated VM) being added: steps, log, error. |
All are for platform admins.