An orchestrator running microVMs on demand. Built for AI agent sandboxes, CI engines, and more.
README.md

Hyperfleet #

Go version Firecracker

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/kvm accessible
  • 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:

hyperfleet-forgejo-plugin

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/sh on /dev/console for the SSH gateway to attach to. The OCI image's Entrypoint/Cmd are 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).

License #

AGPL-3.0