HTTP API
The server binds KAIO_ADDR (default 127.0.0.1:8080) and serves the
frontend alongside these endpoints. Kaio has no authentication: whatever reaches its port controls the Docker
daemon, which is root-equivalent on that host. Put your own layer in front
before this port is reachable beyond localhost: see
Security.
System
Section titled “System”| Endpoint | Method | Description |
|---|---|---|
/api/health |
GET |
Health check. Returns status, version, sha, the git commit the binary was built from, and server_id, this server’s identity, which is how a probe tells it apart from another machine that took its address |
/api/dashboard |
GET |
Current system summary and full container list |
/api/events |
GET |
SSE stream for real-time Docker events |
/api/events/history |
GET |
Historical events (limited to 100) |
| Endpoint | Method | Description |
|---|---|---|
/mcp |
POST |
Model Context Protocol over streamable HTTP, for AI agents. Not routed unless KAIO_MCP names a mode, and guarded by its own Host and Origin allowlists rather than the API’s CORS policy. See MCP server |
Metrics
Section titled “Metrics”| Endpoint | Method | Description |
|---|---|---|
/metrics |
GET |
Prometheus exposition of the state Kaio knows: stacks, containers, pending updates, CVE counts, cluster health and Kaio’s own loops. Not routed unless KAIO_METRICS=on, and requires the bearer token KAIO_METRICS_TOKEN names when one is set. See Prometheus and Grafana |
/metrics/targets |
GET |
The nodes of this server’s registry in Prometheus’ HTTP service discovery format, so one Kaio hands Prometheus the rest of the cluster. Each group carries a node label. Same flag and same token as /metrics |
Cluster
Section titled “Cluster”How a server joins another one’s registry. See Multiple servers.
| Endpoint | Method | Description |
|---|---|---|
/api/cluster |
GET |
Whether this server is a control plane, a member, or neither |
/api/cluster/token |
GET |
The join token. 404 when the server has none: reading never creates a credential |
/api/cluster/token |
POST |
Mint one, retiring any previous. This is the only route that creates it |
/api/cluster/register |
POST |
Called by a joining node: {token, server_id, name?, advertise?}. On a valid token the node is added to the registry under its own server_id, probed, and its settled {name, url, status, error} returned. A server that is the control plane itself is refused |
/api/cluster/join |
POST |
Asks this server to join the cluster in {control_plane_url, token, name?, advertise?} |
/api/cluster/membership |
DELETE |
Leave: asks the control plane to drop this server’s registry entry, then forgets it locally. Returns {left, removed, error}, and still forgets when the control plane cannot be reached |
register reads the connection’s peer address when advertise is missing, so
a node behind a wildcard bind is enrolled without naming its own address.
The registry of Kaio instances this server pilots. See Multiple servers.
| Endpoint | Method | Description |
|---|---|---|
/api/nodes |
GET |
List the nodes that have joined, ordered by name |
/api/nodes/{name} |
GET |
One node |
/api/nodes/{name} |
DELETE |
Drop the node from the registry. The node itself is untouched; it rejoins by running kaio-cli join again |
/api/nodes/{name}/check |
POST |
Probe one node and return it with the result |
/api/nodes/{name}/proxy/{*path} |
any | Forward the request to that node’s own API, at {node url}/{path}. A node that is not in the registry answers 404: the registry is what makes a node reachable this way |
/api/nodes/check |
POST |
Probe every node and return them all |
Through the gateway
Section titled “Through the gateway”/api/nodes/{name}/proxy/{*path} makes every route on this page reachable on a
node: GET /api/nodes/prod-01/proxy/api/dashboard answers with prod-01’s
dashboard. The query string is carried over and all three transports survive the
hop: JSON with any method and body, SSE streamed rather than buffered, and a
WebSocket upgrade bridged socket to socket so the container terminal works.
Request bodies are streamed too, so an upload crosses the gateway at constant
memory whatever its size. A Content-Length is replayed unchanged to the node;
a chunked upload stays chunked. Authorization is not forwarded.
Reaching a node this way requires it to be in the registry. Removing it removes the route, which is the only thing the gateway checks: the node’s own address stays as open as it was.
Nodes only enter the registry by joining (see the cluster routes above), and
nothing here edits one. status is the result of the last probe: online,
offline, unauthorized or unknown.
Containers
Section titled “Containers”| Endpoint | Method | Description |
|---|---|---|
/api/containers/{id} |
DELETE |
Delete a container |
/api/containers/{id}/action |
POST |
start, stop, restart, pause, unpause |
/api/containers/{id}/logs |
GET |
SSE stream of logs. ?follow=false for a one-shot snapshot of the last lines; ?tail= overrides KAIO_LOGS_TAIL |
/api/containers/{id}/shell |
WS |
WebSocket for interactive sh access |
Stacks
Section titled “Stacks”| Endpoint | Method | Description |
|---|---|---|
/api/stacks |
POST |
Deploy a stack (name, content, optional env array replacing the stack environment) |
/api/stacks/{name} |
DELETE |
Remove a stack, its files and its containers |
/api/stacks/{name}/update |
POST |
Pull and update (pull + up -d) |
/api/stacks/{name}/restart |
POST |
Restart |
/api/stacks/{name}/start |
POST |
Start every stopped container of the stack |
/api/stacks/{name}/pause |
POST |
Pause every running container of the stack |
/api/stacks/{name}/unpause |
POST |
Resume every paused container of the stack |
/api/stacks/{name}/scan |
POST |
Start a Trivy scan of all the stack’s images |
/api/stacks/{name}/check-update |
POST |
Trigger a remote image update check |
/api/stacks/{name}/logs |
GET |
SSE stream merging the logs of every container of the stack, each line prefixed by its service. ?tail= overrides KAIO_LOGS_TAIL, per container |
/api/stacks/{name}/compose |
GET |
Retrieve the docker-compose.yml |
Stack environment
Section titled “Stack environment”| Endpoint | Method | Description |
|---|---|---|
/api/stacks/{name}/env |
GET |
List variables: [{key, value, secret}], secret values returned as null |
/api/stacks/{name}/env |
PUT |
Replace the environment without deploying (a null value keeps the stored one). Returns env_pending |
/api/stacks/{name}/env/export |
GET |
Same list with secret values in the clear, for a server moving a stack to another server. Requires the cluster token as Authorization: Bearer <token> and answers 401 without it |
/api/stacks/{name}/apply |
POST |
Redeploy with the stored environment, create a version and clear env_pending |
Copying a stack to another server
Section titled “Copying a stack to another server”| Endpoint | Method | Description |
|---|---|---|
/api/stacks/{name}/copy |
POST |
Copy a stack to another node, or onto this one under a new name. Answers the plan it followed |
This is the control plane’s own route, not a node’s: it orchestrates the pieces below. The source is never written to, never stopped and never deleted, so a failed copy costs time rather than data.
{ "from": "prod-01", "to": "prod-02", "as": "web-staging", "include_volumes": true, "start": false, "allow_binds": false, "allow_anonymous_volumes": false, "dry_run": false}Every field is optional. from and to name nodes in the registry; leaving one
out means this server. as defaults to the source name, and must differ from it
when source and destination are the same server. start defaults to false:
a copy that came up on its own would leave two live stacks contending for the
same DNS, webhooks, queues and mail.
dry_run returns the plan and changes nothing, blockers included, so a caller
can show them before asking for confirmation. A real copy refuses on those same
blockers.
The plan carries volumes (each with the name it will take on the destination),
binds, images, warnings and blockers. A copy stops before touching the
destination when:
- the destination already runs a stack by that name;
- a port the stack publishes is already published there, whether or not the copy
was asked to start, because the result could never come up.
allow_port_clashaccepts writing it all the same; - a volume keeps its own name on the destination (an explicit
name:, or anexternalone) and both ends are the same server, because the copy would then mount the volume the source is running on; - the stack mounts host paths, unless
allow_bindssays to accept that they stay behind; - a volume is anonymous and
include_volumeswas asked for, unlessallow_anonymous_volumessays to accept losing it. Declaring the volume undervolumes:in the compose file and redeploying is the better answer: an anonymous volume holding real data would otherwise produce a copy that looks healthy and is empty.
Named volumes are renamed to follow the destination project: copying web as
web-staging moves web_data into web-staging_data. A volume given an
explicit name: in the compose file keeps it, and an external one is left
alone; on another server that is what you want, and on the same server it is
refused rather than quietly shared.
A volume declared under volumes: that Docker has never created is left out of
the plan rather than failing the copy, and a name: written with ${VAR} is
interpolated with the stack’s environment before it is looked up.
If anything fails after the destination has been written to, the copy undoes
itself there: the volumes it created are removed, the stack it imported is
removed, and a volume that already existed is never touched. When even that
fails, an error event names what to remove by hand.
Moving a stack to another server
Section titled “Moving a stack to another server”These routes carry a stack, its secrets and its data off the machine, so each
one asks for the cluster token in Authorization: Bearer <token>, the way
/api/stacks/{name}/env/export does above, and answers 401 without it. They
are the pieces a copy or a migration is built from: none of them moves anything
by itself.
| Endpoint | Method | Description |
|---|---|---|
/api/stacks/{name}/export |
GET |
The whole stack as JSON: compose file, environment with its secrets in the clear, every version with its own environment, the images it references, and an inventory of what it mounts. ?sizes=true also measures each volume, which costs one throwaway container |
/api/stacks/import |
POST |
Write a stack here from that JSON, under name, without starting it. Refuses a name already taken, files already present, or a current_version missing from versions |
/api/volumes/{name}/export |
GET |
Stream the volume as an uncompressed tar (application/x-tar), read from a throwaway container |
/api/volumes/{name}/import |
POST |
Unpack such a tar into the volume, creating it when absent. Refuses a volume that already holds data unless ?force=true |
/api/stacks/{name}/up |
POST |
Bring an imported stack up without adding a version to its history |
The mount inventory separates what can be moved from what cannot:
volumesare the named volumes, each with the Compose key it comes from (web_datafromdataunder the projectweb), where each service mounts it, whether it isexternal, and whether it isanonymous. An anonymous volume is one Docker named itself: its data is real but its name is not, so it cannot be recreated by name on another host.bindsare the host paths the stack mounts. They are never transferred. A path that exists on one server has no reason to exist on the next, and copying it blindly would land the wrong ownership on the wrong files, so the inventory reports them and stops there.
An external: true volume is reported but belongs to something else, so a copy
leaves it alone.
The throwaway container runs KAIO_TRANSFER_IMAGE (default alpine:3), pulled
on first use, with no network and nothing mounted but the volume.
Stack versions
Section titled “Stack versions”| Endpoint | Method | Description |
|---|---|---|
/api/stacks/{name}/versions |
GET |
List all historical versions |
/api/stacks/{name}/rollback/{version} |
POST |
Roll back to a specific version |
/api/stacks/{name}/versions/{version} |
DELETE |
Delete a specific version |
Images
Section titled “Images”| Endpoint | Method | Description |
|---|---|---|
/api/images |
GET |
List local images, enriched with scan summaries |
/api/images/scan |
POST |
Start a Trivy scan for every tagged image on the host |
/api/images/{id} |
DELETE |
Forcefully delete an image |
/api/images/{id}/scan |
POST |
Start a scan for one image (asynchronous) |
/api/images/{id}/scan |
GET |
Latest scan result, with the full CVE list |
Volumes & networks
Section titled “Volumes & networks”| Endpoint | Method | Description |
|---|---|---|
/api/volumes |
GET |
List volumes |
/api/volumes/{name} |
DELETE |
Delete a volume |
/api/networks |
GET |
List networks |
/api/networks/{id} |
DELETE |
Delete a network |
Pruning
Section titled “Pruning”| Endpoint | Method | Description |
|---|---|---|
/api/prune/images |
POST |
Prune dangling and unused images |
/api/prune/containers |
POST |
Prune stopped containers |
/api/prune/volumes |
POST |
Prune unused local volumes |
/api/prune/networks |
POST |
Prune unused networks |
Streaming formats
Section titled “Streaming formats”- SSE (
/api/events, log endpoints): one JSON object per line. Log lines carryservice,container,stream,tsandline. - WebSocket (
/api/containers/{id}/shell): raw PTY bytes in both directions.
Related
Section titled “Related”Kaio, built by Régis Gaidot