Appearance
Deploy a server
Run hopboxd on a Linux host with systemd so your boxes are reachable beyond your laptop.
One-command install
sh
curl -fsSL https://hopbox.dev/install.sh | sudo shPrivate beta
The repo is currently private, so the install one-liners need repo access (a token or a deploy key). Ask us for access — or just use the hosted box.hopbox.dev, which needs no install at all.
It installs hopboxd + hopbox-mcp + the in-box hopbox-agent + box-guest, writes a systemd unit, turns the AI-control MCP plane on by default (a local Unix socket), and starts on the docker backend (zero extra setup). Config lives in /etc/hopbox/hopboxd.yaml; edit and systemctl restart hopboxd.
sh
ssh box@<your-host> # spawn a box on your serverFirecracker microVM backend
For hardware isolation and snapshot suspend/resume, run boxes as microVMs. The host needs /dev/kvm (bare metal or a nested-virt VPS). Install with the backend selected, build the image catalog once, then restart:
sh
curl -fsSL .../install-hopbox.sh | sudo HOPBOX_COMPUTE=microvm sh
sudo build/microvm/catalog.sh # build the curated catalog (ubuntu/debian/python/node/go)
sudo systemctl restart hopboxdcatalog.sh is the canonical, reproducible definition of what ssh images@host offers — each image is a debootstrap base plus its toolchain, with a matching per-image skill. Build one with sudo build/microvm/catalog.sh python, or the golden CI base with build/microvm/build-rootfs.sh.
This writes /opt/hopbox-microvm/vmlinux + /opt/hopbox-microvm/images/<name>.ext4. The fleet runs on its own host bridge (--fc-bridge, default hopbox-vmnet) and /24 (--fc-subnet, default 10.0.0); set both to run a fleet beside another daemon. See the microVM flags.
Zero-touch microVM host
For a dedicated hopbox server (bare metal or a nested-virt VPS), provision.sh stands up a complete microVM host in one command, with no manual steps — base packages, Go, Firecracker, the guest kernel, a source build of hopbox, the image catalog, a systemd service, a daily off-host backup timer, and start:
sh
curl -fsSL https://hopbox.dev/provision.sh | sudo bashIt needs root + /dev/kvm. The script itself installs only the prerequisites — base packages, Go, Firecracker, the guest kernel, juicefs — builds hopbox into a staging bundle, and then hands the whole host-shaping job to hopbox-host bootstrap --from: the config, the systemd unit, the deploy, the image catalog, and the backup timer.
That split is deliberate. Provisioning used to do all of it in bash, and twice it drifted from what hopbox-host already owned — building over the release symlinks, and rebuilding the catalog without recording the drift fingerprint. Neither failed loudly. Keeping one implementation, in the program that owns the contract, is what stops a third variant of that bug; the shell keeps the parts that are genuinely shell-shaped, where a Go rewrite would just need Go to install Go.
So the host-adapting behaviour — picking a front-door port that never displaces the host sshd, dropping capability-capping service drop-ins, pruning accumulated <file>.bak* configs — is documented with bootstrap. provision.sh still marks /opt/hopbox-src a git safe.directory (system-wide), so git -C /opt/hopbox-src log works for a non-root login, and — when the host runs UFW — opens the microVM bridge so the in-box agent can reach the hub. Everything else is tunable in /etc/hopbox/hopboxd.yaml (see the config reference).
Re-running it on a host hopbox-host already manages is a deploy, and goes through the deploy pipeline — --checked, health-gated, rolled back on failure, and recorded, the same as any other deploy. That includes the channel rule: the provisioner builds from source, so re-running it on a host that runs a release would move it to trunk, and is refused unless HOPBOX_CHANNEL=trunk says so. It never writes binaries over /usr/local/bin on such a host: those names are release symlinks, and replacing them would evict the host from release management while leaving deployed-sha reporting a version it is no longer running. A fresh host has no release layout to protect, so it installs directly and the first hopbox-host update migrates that into /opt/hopbox/releases. Both paths are described under bootstrap.
Re-running is also how a host picks up a changed systemd unit: hopbox-host update ships binaries, not units, so the drain budget below reaches an existing host only when the provisioner (or bootstrap) runs.
It also enables hopbox-check.timer, which watches the host between deploys — the deploy path is well covered, but until now nothing noticed a daemon that went unhealthy an hour later, or a binary replaced by hand. Every 5 minutes hopbox-host check probes the front door and compares the release dir and config against fingerprints recorded at deploy time. Point HOPBOX_ALERT_WEBHOOK in /etc/hopbox/alert.env at a webhook to be told; it fires on a state change, not on every tick.
It also installs hopbox-backup and enables hopbox-backup.timer (daily), but can't pick an off-host destination for you — until HOPBOX_BACKUP_DEST is set in /etc/hopbox/backup.env, every backup run fails loudly (exit 78) rather than silently backing up nothing, and it prints a reminder at the end. See Backups & restore.
Use this for a full microVM host; use the one-command install for a quick docker-backed one.
Updating — hopbox-host
Updates run through hopbox-host, a small tool that deploys safely: it gets the new version's binaries, validates the config against them, swaps them in atomically, restarts behind a health gate, and rolls back to the previous release if the new one doesn't come up healthy — so a bad build or config never leaves the daemon down.
sh
hopbox-host update --from ./bundle # deploy prebuilt artifacts — the production path
hopbox-host update --ref main # or build here, on a host that has a checkout
hopbox-host status # channel + version, health, releases, image drift, legacy layout
hopbox-host rollback # return to the previous releaseIt gets those binaries one of two ways. Both run the identical pipeline — only how the release dir gets filled differs:
- Deploy prebuilt artifacts (
--from <dir|tar.gz>) — binaries built anywhere (CI,make dist, a release tarball). This is the path for a host serving users. No Go toolchain and no/opt/hopbox-srccheckout on the production box, nothing to keep in sync there, and the host can run the exact artifact users install. - Build on the host (
--ref, defaultmain) — resolves a git ref in/opt/hopbox-srcand compiles it there. Convenient on a development host, where a toolchain and a checkout are wanted anyway; on a production one they are extra attack surface and a slower deploy for no benefit.
--from and --ref are alternatives — an explicit --ref alongside --from is refused. A bundle is validated (it must declare its version, and every binary must be present, executable, and built for the host's architecture) before anything touches a release dir, so a bad or mismatched bundle fails with the daemon untouched.
The health gate
The gate is two steps. First the cheap hopboxd --health poll — is the restarted daemon back up and answering its front door at all? Then, once, right before the sha is recorded, --health-full — can the daemon actually do its job: spawn a box through its own front door, run a command in it, resolve the image catalog, reach the workspace store? A release that starts cleanly but can't spawn a box (a broken Firecracker path, a missing kernel or rootfs, an unreachable workspace store, an empty catalog) used to sail through the banner check and get recorded as deployed with no rollback ever firing; now it fails the deep probe and rolls back exactly like a daemon that never came back up. Either way hopbox-host logs the probe's per-check breakdown (hopbox-host: deep health | … lines) — the record of what was proven, on success, or the answer to "which check failed?" while the rollback runs, on failure.
Two paths deliberately stay on the cheap probe only. Re-running update when already on the target and healthy is a no-op checked with --health — a drift-aware re-run must not spawn a box on a host that's already serving. And hopbox-host rollback (and the rollback update triggers) also gates on --health: a host-level fault, like an unreachable object store or a wiped image catalog, would fail the deep probe on every release, so rollback reports the honest "restored and serving" outcome instead of blaming the release it just restored. hopbox-host status is unchanged too — always the cheap probe.
Deploying a release
Tag it, let CI build and publish it, then point a host at the published artifact:
sh
git tag v0.6.2 && git push origin v0.6.2 # CI builds + mirrors to hopbox.dev/dlsh
curl -fsSLO https://hopbox.dev/dl/latest/hopbox_linux_amd64.tar.gz
sudo hopbox-host update --from hopbox_linux_amd64.tar.gzThe host stays on the release channel, and hopbox-host status can prove it is running the same bits an installer would fetch.
Deploying an unreleased change
To get a change onto a host without cutting a release, build the bundle where the code is and ship it:
sh
make deploy HOST=ks # ARCH=arm64 for an arm hostThat cross-compiles this working tree for the host, copies the bundle over, refreshes hopbox-host itself (never part of a release swap, so update cannot replace it), and runs the same gated deploy. Nothing but the bundle lands on the host. make dist + scp + hopbox-host update --from by hand does the same thing if you want the steps.
A local build is not a release — it is the trunk channel, whatever it is built from — so the first make deploy onto a host running a release needs CHANNEL=trunk to say that is meant. See below.
The channel is a property of the host
hopbox-host status reports a channel: release (a prebuilt bundle that declares itself one — what users install) or trunk (anything compiled on the host, and any bundle that doesn't claim to be a release — a build only that host has ever seen).
A deploy that would move a host between channels is refused unless --channel <trunk|release> names where it is going:
$ sudo hopbox-host update --ref main
hopbox-host: refusing to move this host from the release channel to trunk — that changes what this host IS, and nothing asked for it
deployed: v0.6.1 (release) — artifacts /tmp/hopbox_linux_amd64.tar.gz
target: 9c1f0b1a2b3c (trunk) — source build of main in /opt/hopbox-src
trunk is a build only this host has ever seen; release is the artifact users install
keep it on release: hopbox-host update --from <hopbox_linux_amd64.tar.gz> (https://hopbox.dev/dl/latest/)
really move it to trunk: re-run the same command with --channel trunkThis exists because it happened: a routine update --ref main on a production host running v0.6.1 (release) deployed fine, health-gated correctly, and quietly left the host on an untagged trunk build — no warning, no confirmation, nothing in the output. A later --from of a release tarball would have moved it back the same way. Which channel a host runs is a property of the host, not a side effect of which flag someone typed, so crossing that line is now something you ask for.
The everyday deploys need no flag at all: release → release and trunk → trunk are not channel changes. Neither is the first deploy to a host with nothing recorded (a fresh one, or one deployed before channels were tracked) — that adopts a channel rather than changing one. --channel also has to match what the deploy actually is, so it can't be used as a blanket "yes": asserting --channel release over a source build is refused, because compiling a tag on a host still doesn't make the artifact users install.
When a change does go through, it is announced where you are already looking:
hopbox-host: deployed 9c1f0b1a2b3c (trunk) — channel changed release -> trunkprovision.sh builds from source, so re-running the provisioner on a release host is a channel change too, held to the same rule — pass HOPBOX_CHANNEL=trunk to allow it.
What status reports
A host records what it deployed, so status answers both "which version" and "which pipeline built it":
channel: release
version: v0.6.1
deployed: 9c1f0b1a2b3c
source: artifacts /root/hopbox_linux_amd64.tar.gz
health: healthy
service: active
releases: 3 (newest first) …
images: in sync
legacy: (only when a pre-releases/ layout is present — see `hopbox-host clean`)On a host that is not running the artifact users install, the channel line says so rather than leaving a bare word that looks the same on a dev box and a production one:
channel: trunk (an unreleased build — NOT the artifact users install)That is a note, not an alarm — trunk is the right answer on a development host, and hopbox-host check never pages on it, because getting there now takes an explicit --channel.
The images: line is the reminder to rebuild the image catalog (hopbox-host catalog build) after an update that changes what the images bake, so new boxes pick it up — image builds are decoupled from the daemon update and never run on the deploy path. status prints it; update itself does not. It covers everything baked into an image — hopbox-agent, box-guest and the microVM's hopbox-init — and names which one drifted, so a box-guest-only change can't slip through as in sync. It stays in sync when nothing baked actually changed: those binaries are built without Go's commit stamp, so a deploy that touches only the daemon leaves them byte-identical. That rebuild covers every image including claude-code, so a change to box-guest reaches agent boxes too; it is the slowest part, since that image carries a full toolchain.
Is /opt/hopbox-src worth keeping on a production host?
Only if the host builds something. The checkout (plus a Go toolchain) is needed for:
update --ref— the build-on-host deploy path;catalog build— the microVM image catalog is defined bybuild/microvm/catalog.shand the guest assets in the source tree, so a microVM host that rebuilds its own images needs it.
Nothing else does. A docker-backed host, or any host whose images are built elsewhere, can be deployed to entirely with --from and carry no source and no compiler at all. Everything a release needs — including hopbox-backup, which is a script rather than a compiled binary — ships inside the bundle, so an artifact-fed host keeps its backup/restore tool in step with its daemon instead of frozen at provisioning time.
On a host that does keep the checkout, every build-on-host run (and provision.sh) marks /opt/hopbox-src a git safe.directory system-wide, so a non-root git -C /opt/hopbox-src log works for a human checking what's deployed. A --from deploy skips that step — it needs no checkout to mark.
Cleaning up the pre-releases/ layout
A host provisioned before the <base>/releases/<sha> layout landed still carries its old v<version> install directories (and a vestigial current symlink) directly under /opt/hopbox — update --releases N's GC only ever looks inside /opt/hopbox/releases, so it can never see or collect them, and they just sit there (commonly several hundred MB to ~1GB). hopbox-host status reports them (legacy: …) when present, and stays silent when there's nothing there. hopbox-host clean collects them:
sh
hopbox-host clean # preview: lists each path, its size, and the total — deletes nothing
hopbox-host clean --apply # actually remove themIt's a separate subcommand, not a step of update — a one-off delete of this size does not belong inside an unattended deploy window. See the hopbox-host reference for the full safety guards.
Graceful shutdown & the restart budget
hopboxd's unit uses KillMode=mixed: only the daemon gets SIGTERM on stop, so it can drain — suspend every running, persistent box — before the box processes under it die. There's no fixed wall-clock budget for the drain as a whole; a fleet's worth of snapshots doesn't fit one constant, and boxes past an old fixed cutoff used to be SIGKILLed with their state gone. Instead hopboxd speaks systemd's notification protocol (sd_notify): every box it finishes snapshotting pushes systemd's stop deadline out (EXTEND_TIMEOUT_USEC=90s) and sets a draining: N/M persistent boxes suspended line visible in systemctl status hopboxd — so the deadline tracks real progress instead of guessing at it.
That needs two settings in the unit, both written by install-hopbox.sh and provision.sh:
ini
NotifyAccess=main # lets hopboxd (not itself Type=notify) send sd_notify messages
TimeoutStopSec=300 # the backstop for a drain making NO progress at all — not a budgetTimeoutStopSec=300 only fires if hopboxd stops extending it; a drain that keeps saving boxes never hits it.
Existing hosts need the unit re-written
hopbox-host update deploys a new binary — it does not rewrite the systemd unit. A host installed before this changed keeps its old unit, where NotifyAccess is unset: $NOTIFY_SOCKET is never set, hopboxd's sd_notify calls become silent no-ops, and the old fixed TimeoutStopSec=60 still applies. Re-run install-hopbox.sh or provision.sh, or hand-edit the unit (add NotifyAccess=main, raise TimeoutStopSec) and systemctl daemon-reload, to get the new behavior.
hopbox-host itself is never replaced by update, built or prebuilt — it is the thing doing the swapping. Install a new one from the bundle with sudo install -m755 hopbox-host /usr/local/bin/hopbox-host; make deploy and provision.sh both do exactly that before handing over the deploy. See the hopbox-host reference for the full flag set and the artifact-validation rules.
The AI-control plane & canvas
The installer enables the MCP plane on unix:/run/hopboxd-mcp.sock. To also serve the AI-rendered canvas surfaces over HTTP, set surface-addr (e.g. :8094) + surface-url in /etc/hopbox/hopboxd.yaml and restart. See The AI-control plane.
Web presence (optional)
The SSH front door (:22) is all a hopbox host needs. If you also want a web presence on the host, run Caddy alongside hopboxd (deploy/Caddyfile.example) to serve a static page or redirect to your product site — :22 and :443 are independent. (The live box.hopbox.dev fronts the HTTP API + MCP at /v1, canvas surfaces at /s/*, and the web console at /app/; everything else redirects to hopbox.dev.)
Expanding
A complete production guide (hardening, upgrades) is in progress. For backups, see Backups & restore.