Hyperfleet #
An orchestrator running microVMs on demand. Built for AI agent sandboxes, CI engines, and more.
Contents #
Installation #
Requirements #
Tested on Ubuntu 24.04.4 LTS (Noble Numbat), kernel 6.17 / x86_64. Other modern Linux distros should work but haven't been verified.
- Linux x86_64 or aarch64 (Firecracker is Linux-only)
- KVM enabled and
/dev/kvmaccessible systemd,sudo,git- Go ≥ 1.26
dmsetup,losetup,mkfs.ext4(devmapper thin pool)- ~12 GB free disk for the default loopback thin pool
Setup #
git clone https://github.com/alexisbchz/hyperfleet.git
cd hyperfleet
make bootstrap # install containerd + runc, devmapper thin pool, firecracker, vmlinux
make setup # gum-driven: create containerd group, drop-in config, add $USER to it
newgrp containerd # pick up the group in the current shell (or log out/in)
make run # start the dev daemon on :8080 + ssh gateway on :2222
In another terminal, build and use the CLI:
make fleet # builds ./bin/fleet
export HYPERFLEET_API_KEY=<key from `make run` output> # or set it in ~/.zshrc
./bin/fleet machines create docker.io/library/alpine:3.20
./bin/fleet machines list
./bin/fleet machines ssh
make bootstrap is idempotent; re-runs are safe. make setup only needs to run
once per host.
Commands #
serve(1) — hyperfleet daemon #
SYNOPSIS
serve [--addr :8080] [--ssh-addr :2222] [--api-key KEY]
[--containerd-sock PATH] [--namespace NAME] [--snapshotter NAME]
[--firecracker-bin PATH] [--kernel-path PATH] [--work-root DIR]
DESCRIPTION
Runs the REST API (/machines) and SSH gateway. Each POST /machines
provisions a microVM asynchronously; SSH sessions attach to the VM's serial
console.
OPTIONS
| flag | env | default |
|---|---|---|
--addr |
ADDR |
:8080 |
--ssh-addr |
SSH_ADDR |
:2222 |
--api-key |
HYPERFLEET_API_KEY |
ephemeral random |
--containerd-sock |
CONTAINERD_SOCK |
/run/containerd/containerd.sock |
--namespace |
CONTAINERD_NAMESPACE |
hyperfleet |
--snapshotter |
SNAPSHOTTER |
devmapper |
--firecracker-bin |
FIRECRACKER_BIN |
./bin/firecracker |
--kernel-path |
KERNEL_PATH |
./assets/vmlinux |
--work-root |
WORK_ROOT |
./run |
fleet(1) — hyperfleet CLI #
SYNOPSIS
fleet [--api-url URL] [--api-key KEY] [--ssh-host H] [--ssh-port P]
[--output table|json] [--non-interactive] <command>
COMMANDS
fleet machines create [<image>] create a machine; prompts for image if omitted
fleet machines list list machines
fleet machines get [<id>] show one machine; prompts for id if omitted
fleet machines delete [<id>] delete a machine; prompts + confirm if id omitted
fleet machines ssh [<id>] attach an interactive shell over the SSH gateway
OPTIONS
| flag | env | default |
|---|---|---|
--api-url |
FLEET_API_URL |
http://localhost:8080 |
--api-key |
HYPERFLEET_API_KEY |
required |
--ssh-host |
FLEET_SSH_HOST |
api-url host |
--ssh-port |
FLEET_SSH_PORT |
2222 |
--output, -o |
— | table |
--non-interactive |
FLEET_NON_INTERACTIVE |
auto: off when stdin is a TTY |
EXAMPLES
fleet machines create docker.io/library/alpine:3.20
fleet machines list -o json
fleet machines ssh # interactive picker
Make targets #
make setup # gum-driven host setup: containerd group + permissions
make install-containerd # install containerd v2 + runc + systemd unit
make install-firecracker # install Firecracker binary into ./bin
make setup-devmapper # create loopback thin pool + drop-in config
make kernel # download vmlinux into ./assets
make bootstrap # install-containerd + setup-devmapper + install-firecracker + kernel
make run # go run ./cmd/serve
make stop # find & kill whatever is bound to :8080 (with confirm)
make build # build ./bin/serve and ./bin/fleet
make fleet # build only ./bin/fleet
make tidy # go mod tidy
REST API #
Base URL: http://<host>:8080 (default). Content type: application/json for both
request and response bodies. Timestamps are RFC 3339 / ISO 8601 in UTC.
Authentication #
All endpoints under /machines require the API key on every request. Two
schemes are accepted (use either):
X-API-Key: <key>
Authorization: Bearer <key>
Missing or wrong key → 401 Unauthorized. The OpenAPI document and Stoplight
docs UI (/openapi.json, /openapi.yaml, /docs) are intentionally public.
Resource: Machine #
{
"id": string, // CUID, immutable, e.g. "ck5g9k1xa0000g0qja1xrjgqa"
"image": string, // OCI image reference as supplied at create time
"status": string, // one of: "pending" | "running" | "exited" | "failed"
"createdAt": string, // RFC 3339, set on POST /machines
"startedAt": string, // RFC 3339, present once status == "running"
"exitedAt": string, // RFC 3339, present once status ∈ {"exited","failed"}
"error": string // present iff status == "failed"; human-readable cause
}
State machine:
┌──────────► running ──────────► exited
created (pending) ─┤
└──────────► failed
pending → running requires: lease acquisition, image pull, snapshot prepare,
work-dir creation, Firecracker start. Any step failing transitions directly to
failed with error set. running → exited is the normal termination path
(VMM exits cleanly). running → failed happens if m.Wait returns an error
that wasn't caused by host-initiated cancellation (i.e. DELETE).
Endpoints #
POST /machines — create #
Provisions a machine asynchronously and returns immediately.
Request body:
{ "image": "docker.io/library/alpine:3.20" }
202 Accepted response (representative):
{
"id": "ck5g9k1xa0000g0qja1xrjgqa",
"image": "docker.io/library/alpine:3.20",
"status": "pending",
"createdAt": "2026-05-04T09:10:31.997Z"
}
Status codes:
| code | meaning |
|---|---|
202 |
accepted; provisioning runs in background |
400 |
image missing or empty |
401 |
bad / missing API key |
Idempotency: not supported — every call creates a new resource with a fresh CUID.
Notes:
- The HTTP response is sent before the image pull starts. Subsequent
GET /machines/{id}reflects progress. - Provisioning holds a containerd lease scoped to 15 minutes (
leases.WithExpiration) so leaked snapshots are GC'd automatically if the daemon crashes.
GET /machines — list #
Returns all known machines (in-memory; persistence is deferred).
200 OK:
{ "machines": [ { /* Machine */ }, … ] }
No pagination, no filtering in v0. Order is unspecified.
GET /machines/{id} — get #
| code | meaning |
|---|---|
200 |
body is a Machine |
401 |
bad / missing API key |
404 |
unknown id |
DELETE /machines/{id} — delete #
Cancels the per-machine context (Firecracker exits, snapshot is released, work-dir is removed) and blocks until the lifecycle goroutine has fully returned, then drops the record from the map.
| code | meaning |
|---|---|
204 |
deleted; body is empty |
401 |
bad / missing API key |
404 |
unknown id |
Calling DELETE on an already-exited or failed machine returns 204 and
removes the record. A second DELETE on the same id returns 404.
Errors #
Errors follow RFC 7807 — Problem Details for HTTP APIs
(Huma's default), served as application/problem+json:
{
"$schema": "http://<host>:8080/schemas/ErrorModel.json",
"title": "Not Found",
"status": 404,
"detail": "machine not found"
}
Validation errors include an errors array enumerating each offending field.
The unauthenticated 401 is hand-written (not Problem Details) and returns:
{ "error": "unauthorized" }
with WWW-Authenticate: Bearer realm="hyperfleet".
In-guest control plane #
Each microVM runs a small in-guest init binary (initd/, statically-linked C
on musl, ~750 KB) that listens on AF_VSOCK port 1024. Build it with
make initd; the daemon copies the resulting bin/hyperfleet-init into the
rootfs at /sbin/hyperfleet-init and the kernel boots into it as PID 1.
The daemon proxies four endpoints from initd, all rooted under /machines/{id}
and gated by the same API key as the rest of the API:
| method | path | body | response |
|---|---|---|---|
POST |
/exec |
JSON {command, env, workdir, user} |
framed stream — see below |
PUT |
/files?path=ABS |
tar archive | 204 No Content |
GET |
/files?path=ABS |
— | tar archive |
GET |
/stat?path=ABS |
— | JSON {exists, isDir, mode, size} |
GET |
/healthz |
— | 204 once initd answers |
POST /exec returns an application/octet-stream whose body is a sequence
of frames:
[1 B kind][4 B big-endian length][N B payload]
kind = 1 for stdout, 2 for stderr, 3 for the terminal exit frame
(payload = 4-byte big-endian int32 exit code), 4 for an error frame
(payload = UTF-8 message; emitted instead of frame 3 when the guest could
not run the command at all).
OpenAPI #
| path | content |
|---|---|
/openapi.json |
OpenAPI 3.1 spec (JSON) |
/openapi.yaml |
OpenAPI 3.1 spec (YAML) |
/docs |
Stoplight Elements rendered docs |
/schemas/<Type>.json |
JSON Schemas referenced from $schema fields in responses |
Spec is generated at startup from the registered huma.Operations in
internal/api/api.go; clients can be code-generated from it.
Forgejo Actions integration #
A Forgejo Runner v2 backend plugin that runs CI jobs inside hyperfleet microVMs lives in its own repo:
runner ── go-plugin ──> hyperfleet-forgejo-plugin ── HTTP ──> hyperfleet daemon ── vsock ──> initd
See that repo's README for build, registration, workflow targeting, and
end-to-end screenshots of CI jobs running on hyperfleet microVMs. A
workflow opts in via runs-on: hyperfleet:hyperfleet://<oci-image>.
SSH gateway #
The daemon embeds an SSH server (built on
gliderlabs/ssh wrapping
golang.org/x/crypto/ssh). It is not sshd-in-the-guest — there is no
networking inside the microVM and no per-image key management. Instead, an SSH
session on the host bridges the user's terminal to the VM's serial console
(ttyS0), which is wired through Firecracker's stdin/stdout.
Connection #
ssh -p 2222 <machine-id>@<host>
# or
fleet machines ssh <machine-id>
The daemon listens on --ssh-addr (default :2222). The address space the
SSH server exposes is "machines" — a session targeting username <id> is
routed to the corresponding vmmgr.Manager.Attach(id) call.
Authentication #
Password auth only, in v0:
| field | value |
|---|---|
| username | the machine id (CUID) |
| password | the API key (same value as the REST HYPERFLEET_API_KEY) |
Comparison is constant-time (crypto/subtle.ConstantTimeCompare). Public-key
auth and per-user keys are deferred.
If the machine isn't running (e.g. still pending or already exited), the
session prints the reason and exits with code 1 instead of attaching.
Console multiplexing #
Each VM owns a Console (internal/vmmgr/console.go) that wraps the OS pipes
attached to Firecracker's stdin/stdout:
- stdout (VM → user): a single goroutine reads from the Firecracker stdout pipe and broadcasts each chunk to every attached subscriber. Recent output is also kept in a 64 KiB ring buffer, so newly-attached subscribers see the most-recent activity (e.g., shell prompt) instead of a blank screen.
- stdin (user → VM): writes from any subscriber go straight to the Firecracker stdin pipe. Multiple concurrent attachers share stdin (last-writer-wins, no arbitration).
- Slow subscribers drop chunks rather than blocking the broadcast (per-sub channel buffer = 64 chunks, non-blocking send).
- Closing the SSH session unsubscribes; the VM keeps running.
- Closing the VM closes every subscriber's read side with
io.EOF.
The fleet machines ssh client requests a xterm-256color PTY when stdin is
a TTY and puts the local terminal into raw mode for the duration of the
session, so line discipline is performed by the guest kernel's tty driver, not
the host shell.
Host key #
A persistent ed25519 host key is generated on first start at:
${WORK_ROOT}/sshd_host_ed25519
(default: ./run/sshd_host_ed25519, mode 0600, PKCS#8 PEM). Subsequent
restarts reuse it, so clients' known_hosts entries stay valid. Delete the
file to rotate.
The bundled fleet machines ssh currently uses ssh.InsecureIgnoreHostKey()
— TOFU verification is on the v1+ list.
Limitations #
- No real entrypoint: every VM boots into the in-guest initd, which
forks a
/bin/shon/dev/consolefor the SSH gateway to attach to. The OCI image'sEntrypoint/Cmdare still ignored. Use the in-guest control plane (POST /machines/{id}/exec) for typed command execution instead of relying on the serial shell. - No per-machine SSH host keys: the host key identifies the gateway, not the VM. Machines with the same id across daemon restarts present the same fingerprint.
- Shared stdin between concurrent sessions on the same VM (last-writer-wins).