nebulad serves a REST API that is the embedding surface for both full Nebula and Nebula-slim — same daemon, same API, same SDKs; slim only swaps what runs inside the guest. Everything the CLI can do is (or will be) reachable as plain HTTP: no shelling out, no stdout parsing.
http://127.0.0.1:7440
├── /healthz liveness (auth-exempt)
├── /v1alpha1/... nebula's own resources (status, exec, vessels…)
├── /docker/... the container engine's Docker API, verbatim
└── /k8s/... the kubernetes apiserver, verbatim (slim)
v1alpha1 follows the Kubernetes API versioning convention: alpha means
the surface may change between releases. When it stabilizes it will be
published as /v1/... with a compatibility promise; until then, pin the
nebula version you embed.
| default | override | |
|---|---|---|
| port | 7440 |
api_port in ~/.nebula/config.toml (0 disables) |
| address | 127.0.0.1 |
api_host in config.toml, or NEBULA_API_HOST env |
| auth | off (loopback) | NEBULA_API_TOKEN env → bearer auth |
| port already taken | refuse to start | port_conflict = "auto" in config.toml, or NEBULA_PORT_CONFLICT env |
api_port, dns_port and k8s_port are probed before the VM boots. By
default a conflict is fatal and the error names the port and the other
instance's NEBULA_HOME; with port_conflict = "auto" nebulad binds the next
free port and logs it. Either way the effective ports come back from
GET /v1alpha1/status — read them rather than assuming the configured values.
When NEBULA_API_TOKEN is set, every request except GET /healthz must
carry Authorization: Bearer <token>. Binding a non-loopback address
requires a token — nebulad refuses to serve 0.0.0.0 without one.
NEBULA_API_TOKEN=$(openssl rand -hex 24) NEBULA_API_HOST=0.0.0.0 nebula up
curl -H "Authorization: Bearer $NEBULA_API_TOKEN" http://host:7440/v1alpha1/status| method & path | does |
|---|---|
GET /healthz |
liveness — {"ok":true} (no auth) |
GET /v1alpha1/status |
VM state, cpus/mem, agent health, guest memory, and ports — every listener the daemon owns as {service, addr, ok, error}. An entry with "ok": false is why an otherwise healthy instance serves nothing on it |
GET /v1alpha1/stats |
balloon target, host footprint, guest memory |
POST /v1alpha1/exec |
{"cmd":"uname","args":["-r"],"timeout_ms":30000} → {exit_code, stdout, stderr, timed_out} — runs in the engine guest |
POST /v1alpha1/balloon |
{"target_mib": N} — set the memory target |
GET /v1alpha1/kubeconfig |
standalone kubeconfig YAML (404 until k8s is up) |
GET /v1alpha1/containers |
compat shim; prefer /docker/... below |
The CLI and these endpoints share one implementation (nebula_core::vessels).
| method & path | does |
|---|---|
GET /v1alpha1/vessels |
list (name, running, cpus, mem, gpu, backend) |
POST /v1alpha1/vessels |
create + boot: {"name":"a","cpus":2,"mem_mib":2048,"data_gib":16,"backend":"krun","volumes":["scratch:8"],"no_start":false} (all but name optional). Add "from_image":"alpine:latest" (+optional rootfs_mb) to build the rootfs from a docker image — pulled into the engine via its Docker API, assembled in-guest; or "rootfs_img":"/path/file.img" for a prebuilt raw image |
GET /v1alpha1/vessels/{name} |
one vessel's summary |
DELETE /v1alpha1/vessels/{name}?force=true |
remove (force stops it first) |
POST .../{name}/start · POST .../{name}/stop |
lifecycle |
POST .../{name}/exec |
{"cmd":"sh","args":["-c","..."]} in that vessel |
GET .../{name}/console?tail=N |
last N bytes of the boot/console log |
GET /POST .../{name}/snapshots |
list / take ({"label":"s1","mode":"auto|memory|disk"}) |
DELETE .../{name}/snapshots/{label} |
drop a snapshot |
POST .../{name}/restore |
{"label":"s1"} — live-resumes memory snapshots |
POST .../{name}/branch |
{"new_name":"agent","label":"s1","count":8} (max 64) |
Not exposed (yet): convert-image and reset (CLI-only), gzip'd
rootfs_img (send the raw image), and interactive shell (WebSocket,
planned — exec covers non-interactive use).
Branching is the embedding primitive to know: a memory snapshot fans out into N live mid-execution clones — copy-on-write RAM on macOS/Linux — in about a second per clone:
// eight live forks of a running agent VM, RAM and processes intact
await fetch(`${api}/v1alpha1/vessels/agent0/branch`, {
method: "POST", headers,
body: JSON.stringify({ new_name: "fork", label: "s1", count: 8 }),
});Everything under /docker is proxied verbatim to the engine's Docker
API — dockerd in full Nebula, slimd's reimplementation in slim. The proxy
streams bodies and supports connection upgrades (attach, hijacked
exec), and nebula's bearer auth applies in front.
curl -s http://127.0.0.1:7440/docker/v1.43/version
curl -s "http://127.0.0.1:7440/docker/v1.43/containers/json?all=true"
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"Image":"alpine","Cmd":["echo","hi"]}' \
"http://127.0.0.1:7440/docker/v1.43/containers/create?name=demo"Notes for client libraries:
- Any HTTP client works — the payloads are the [Docker Engine API] (https://docs.docker.com/engine/api/) unmodified.
- SDKs that accept a base URL with a path prefix can point straight at
http://127.0.0.1:7440/docker. - The stock
dockerCLI cannot put a path prefix inDOCKER_HOST. For local CLI use, keep pointing it at the socket nebula already exposes (DOCKER_HOST=unix://~/.nebula/run/docker.sock); the/dockerplane is for programmatic embedders and remote/authized access.
// TypeScript: list containers through the authenticated plane
const api = "http://127.0.0.1:7440";
const headers = { Authorization: `Bearer ${process.env.NEBULA_API_TOKEN}` };
const ps = await fetch(`${api}/docker/v1.43/containers/json?all=true`, { headers });
console.log(await ps.json());Two engines, two mechanisms — by design:
Full Nebula (k3s): fetch the kubeconfig, dial the apiserver directly.
The k8s API speaks TLS with client-certificate auth plus WebSocket/SPDY
subprotocols (exec, port-forward); that cannot be meaningfully
re-proxied through a plain-HTTP server without breaking the cert identity.
The apiserver is already forwarded to the host (port 6443), so clients
connect to it natively:
// TypeScript: drive k8s with the standard client library,
// bootstrapped entirely over the nebula HTTP API.
import * as k8s from "@kubernetes/client-node";
const api = "http://127.0.0.1:7440";
const headers = { Authorization: `Bearer ${process.env.NEBULA_API_TOKEN}` };
// 1. fetch the kubeconfig nebula generated (bearer-gated)
const res = await fetch(`${api}/v1alpha1/kubeconfig`, { headers });
if (!res.ok) throw new Error("k8s not up — POST /v1alpha1/exec or `nebula kube up` first");
const kubeconfigYaml = await res.text();
// 2. load it — the client now talks mTLS straight to the apiserver
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfigYaml);
// 3. use any client API as usual
const core = kc.makeApiClient(k8s.CoreV1Api);
const pods = await core.listPodForAllNamespaces();
console.log(pods.items.map((p) => p.metadata?.name));Caveat: in remote mode the returned kubeconfig still points at
127.0.0.1:6443; rewriting server: to the request host (plus cert SANs)
is a planned follow-up. Local embedding — the primary story — needs nothing.
Nebula-slim: /k8s/... is a verbatim apiserver proxy. slim's
apiserver-lite also serves plain HTTP on a host-side socket
(slim-kube.sock, beside docker.sock), so nebulad can proxy it the same
way as /docker — one port, one bearer token, containers and kubernetes:
// slim: typeless k8s CRUD with nothing but fetch
const deploys = await fetch(`${api}/k8s/apis/apps/v1/namespaces/default/deployments`, { headers });
console.log(await deploys.json());slim also serves the same apiserver over TLS on 6443 (stock kubectl and
client libraries work against it via the kubeconfig flow above), so both
mechanisms work there; /k8s is just the lighter path. On a k3s guest,
/k8s/... answers 501 with a pointer to /v1alpha1/kubeconfig.
You usually don't care — same API. When you do: GET /docker/v1.43/version
returns the engine's own version payload (dockerd reports Docker Engine,
slim reports slim), and /k8s/healthz answering 200 vs 501 distinguishes
the apiserver-lite from k3s.