Skip to content
Kaiov0.6.1

Hardening

Security is the account of what Kaio’s port is worth: whatever reaches it drives the Docker daemon, which is root on that host. This page is the other half, the setups that close it, each one written out in full, and at the end the commands that tell you whether it worked.

Pick one profile and do all of it. Half a profile is an open port with a password next to it.

Where Kaio runs What to put in front Profile
One host, you alone, from your own machine Nothing. Keep the port on loopback and tunnel to it The tunnel
One host you want in a browser from anywhere TLS and a password at a proxy Caddy and a password
A team, plus pipelines and agents SSO for the people, a credential for the machines SSO for people, a credential for machines
Several hosts, with the CLI, the TUI and agents on all of them A private network, and no public port at all An overlay network

Whatever you pick, the last two sections apply: the host and the service around Kaio, and proving it afterwards.

The cheapest setup is the default one. KAIO_ADDR is already 127.0.0.1:8080, so leave it, publish nothing, and carry the port with you. A Unix socket, KAIO_ADDR=unix:/run/kaio/kaio.sock, is tighter still: nothing without access to the file reaches it, and ssh -L 8080:/run/kaio/kaio.sock carries it the same way.

Terminal window
ssh -N -L 8080:127.0.0.1:8080 prod-01

The dashboard is then http://127.0.0.1:8080 in your browser, and the CLI and the TUI need no configuration at all, since that address is their default:

Terminal window
kaio-cli status
kaio-cli tui

An agent works the same way, with no variable to set on the server:

Terminal window
claude mcp add --transport http kaio http://127.0.0.1:8080/mcp

/mcp always answers loopback Host values, so KAIO_MCP_ALLOWED_HOSTS stays unset here. Only KAIO_MCP has to be turned on.

In a container, the port is published to loopback on purpose:

services:
kaio:
image: ghcr.io/rgaidot/kaio:latest
ports:
- "127.0.0.1:8080:8080"
environment:
- KAIO_ADDR=0.0.0.0:8080
- KAIO_ALLOWED_NETWORKS=172.31.80.0/24
networks:
- kaio
networks:
kaio:
ipam:
config:
- subnet: 172.31.80.0/24

KAIO_ADDR stays 0.0.0.0 inside the container, because loopback there is the container’s own. The address that decides what is reachable is the left-hand side of ports. A connection through that port reaches Kaio from the Compose network, not from its loopback (Docker shows the network’s gateway, rootless Podman the container’s own address), which is why that network is named in KAIO_ALLOWED_NETWORKS and given to Kaio alone.

Caddy terminates TLS, asks for a password, and streams logs and the shell correctly with one directive:

/etc/caddy/Caddyfile
kaio.example.net {
basic_auth {
regis $2a$14$Zq8X1pS0xkRDRmHMBqvKxuC7tJ5pYf0jF1Oi3eLDfKb7hJm8hQ8xW
}
reverse_proxy 127.0.0.1:8080 {
header_up Remote-User {http.auth.user.id}
header_up X-Kaio-Proxy-Secret {$KAIO_PROXY_SECRET}
flush_interval -1
}
}
/etc/kaio/kaio.env
KAIO_ADDR=127.0.0.1:8080
KAIO_TRUSTED_PROXIES=127.0.0.1
KAIO_PROXY_SECRET_FILE=/etc/kaio/proxy-secret

Mint the hash with caddy hash-password, and leave KAIO_ADDR on 127.0.0.1:8080 so the only way in is through Caddy. header_up Remote-User hands Kaio the name Caddy checked, which is what the audit log records; header_up replaces anything the client sent under that name. The secret is generated as Reverse proxy shows and given to Caddy’s environment. flush_interval -1 is what keeps the event streams live; see Reverse proxy for the nginx and Traefik equivalents.

The CLI goes through it without a new flag. KAIO_ADDR accepts a full URL, and credentials in that URL are sent as Basic auth:

Terminal window
export KAIO_ADDR="https://regis:$(cat ~/.config/kaio/password)@kaio.example.net"
kaio-cli status

Read the password from a file rather than typing it inline, so it stays out of your shell history, and remember that an exported variable is readable by every process you own.

The in-container shell follows too: since 0.6.0 the CLI and the TUI send the credentials of KAIO_ADDR on the shell’s WebSocket handshake as well.

An agent gets the same password, in a header its client sets:

.mcp.json
{
"mcpServers": {
"kaio-prod": {
"type": "http",
"url": "https://kaio.example.net/mcp",
"headers": { "Authorization": "Basic cmVnaXM6cGFzc3dvcmQ=" }
}
}
}

And the server needs to accept the name it is reached at:

Terminal window
KAIO_MCP=read
KAIO_MCP_ALLOWED_HOSTS=kaio.example.net

An interactive login is right for a browser and wrong for everything else: a pipeline and an agent cannot follow a redirect to an identity provider. Serve two names from one backend, one per kind of caller.

/etc/caddy/Caddyfile
kaio.example.net {
request_header -Remote-User
request_header -Remote-Groups
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email
}
reverse_proxy 127.0.0.1:8080 {
header_up X-Kaio-Proxy-Secret {$KAIO_PROXY_SECRET}
flush_interval -1
}
}
kaio-api.example.net {
basic_auth {
ci $2a$14$8rC1sKpQ2mWdF4yT6uVbXeJ9nL0pR3hG5kD7fA1cZ8oY2vB6mN4qS
agent $2a$14$Q1c9kPz7mN3vB5xR8tY2uW4eL6oA0sD3fG5hJ7kL9zX1cV3bN5mQ2
}
reverse_proxy 127.0.0.1:8080 {
header_up Remote-User {http.auth.user.id}
header_up Remote-Groups machine-{http.auth.user.id}
header_up X-Kaio-Proxy-Secret {$KAIO_PROXY_SECRET}
flush_interval -1
}
}
/etc/kaio/kaio.env
KAIO_TRUSTED_PROXIES=127.0.0.1
KAIO_PROXY_SECRET_FILE=/etc/kaio/proxy-secret
KAIO_GROUPS_READ=kaio-viewers,machine-agent
KAIO_GROUPS_WRITE=kaio-devs,machine-ci
KAIO_GROUPS_ADMIN=kaio-admins

The second block gives each machine a group of its own, so the pipeline gets write and the agent read, from the same Kaio. A person comes in through the first block with the groups Authelia knows them by.

Authelia, oauth2-proxy, Authentik, tinyauth and Pocket ID all fit the first block; Reverse proxy has them written out for Caddy, Traefik and nginx. The second block is deliberately Basic rather than a bearer token, because that is the credential the CLI can present: generate the password with openssl rand -hex 32 and treat it as a token.

A pipeline then points at the second name and nothing else changes in it:

env:
KAIO_ADDR: https://ci:${{ secrets.KAIO_TOKEN }}@kaio-api.example.net

See Deploy from CI for the jobs themselves. One credential per caller, one revocation per leak: give the runner, each agent and each admin their own line in the basic_auth block.

Agents belong on the machine name too, with KAIO_MCP_ALLOWED_HOSTS naming it. KAIO_MCP=write is the ceiling; the agent’s own group keeps it at read:

Terminal window
KAIO_MCP=write
KAIO_MCP_ALLOWED_HOSTS=kaio-api.example.net

When Kaio runs on several hosts and the CLI, the TUI, the browser and an agent all have to reach all of them, a private network beats a password on every front: the credential is the device, the shell works, and nothing is published.

With Tailscale, bind Kaio to the tailnet address rather than to loopback, and name the tailnet as the network it accepts:

systemctl edit kaio
[Service]
Environment=KAIO_ADDR=100.84.12.7:8080
Environment=KAIO_ALLOWED_NETWORKS=100.64.0.0/10,fd7a:115c:a1e0::/48

Without the second line, the server refuses to start on that address. sudo kaio-server setup lan 100.64.0.0/10 writes it for you. Add KAIO_NETWORK_LEVEL=read to keep the tailnet’s peers to reading, and see Reverse proxy for Tailscale Serve, which also names each user.

Then let the tailnet decide who reaches it:

{
"acls": [
{
"action": "accept",
"src": ["group:ops"],
"dst": ["tag:kaio:8080"]
}
]
}

Keep Funnel off: it is the one Tailscale feature that puts the port back on the public Internet. The CLI, the TUI and an agent then use the tailnet name:

Terminal window
export KAIO_ADDR=http://prod-01.tailnet-1234.ts.net:8080
claude mcp add --transport http kaio-prod-01 http://prod-01.tailnet-1234.ts.net:8080/mcp
Terminal window
KAIO_MCP=read
KAIO_MCP_ALLOWED_HOSTS=prod-01.tailnet-1234.ts.net

WireGuard is the same shape without the ACL service: bind to the interface address, name the tunnel’s range in KAIO_ALLOWED_NETWORKS, and let the peer list be the allowlist.

Multiple servers join by token, and the control plane afterwards probes and proxies to each node. On an overlay, join with the overlay address:

Terminal window
kaio-cli join http://prod-01.tailnet-1234.ts.net:8080 --token-stdin \
--advertise http://prod-02.tailnet-1234.ts.net:8080

Three things worth doing once the cluster exists:

  • Treat the control plane as the union of its nodes. /api/nodes/{name}/proxy reaches every one of them, so its own exposure has to be the strictest of the set.
  • Let a node leave on its own. kaio-cli cluster leave on the way out drops its row and makes it forget its control plane’s key. It never held the join token, so its departure leaves nothing to rotate.
  • Never move a stack over plain HTTP. GET /api/stacks/{name}/env/export answers with secrets in the clear to a call signed by the control plane, which is how Copying a stack carries variables across. TLS or an encrypted overlay is the difference between that and broadcasting them.

A node needs no proxy at all. Once it has joined, it starts on an address off the loopback with no other variable, and answers only its own loopback and the calls its control plane signs, every one of them since 0.6.0. Its port can then face the control plane directly:

Terminal window
KAIO_ADDR=0.0.0.0:8080
Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://prod-02.example.net:8080/api/dashboard

403 from anywhere but the node itself, while the control plane drives it normally. Putting an authenticating proxy in front of a node is what not to do: the control plane’s probes get 401 and the node reads as offline. An overlay is still worth it for the traffic itself, which signatures authenticate but do not encrypt.

The shipped unit already drops privileges. These directives go further, and they are a drop-in so an upgrade keeps them:

systemctl edit kaio
[Service]
UMask=0077
ProtectSystem=strict
ReadWritePaths=/var/lib/kaio
ProtectProc=invisible
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=yes
LockPersonality=yes

systemd-analyze security kaio scores the result, and journalctl -u kaio tells you if one of them broke something. Kaio shells out to docker compose, to Skopeo and to the scanner, so test a deploy, an update check and a scan after tightening, and do not reach for MemoryDenyWriteExecute or a syscall filter without doing so.

In a container, the equivalent:

services:
kaio:
image: ghcr.io/rgaidot/kaio:latest
ports:
- "127.0.0.1:8080:8080"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
environment:
- KAIO_ADDR=0.0.0.0:8080
- KAIO_ALLOWED_NETWORKS=172.31.80.0/24
- KAIO_DB=sqlite:/app/data/kaio.db
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data:/app/data
networks:
- kaio
networks:
kaio:
ipam:
config:
- subnet: 172.31.80.0/24

Give it its own network rather than network_mode: host, and keep the socket mount: the :ro on it limits nothing, since the daemon takes its orders over the protocol and not through writes to the file.

Two more, both about what is on disk:

Terminal window
chmod 700 /var/lib/kaio
tar -C /var/lib -cf - kaio | age -r age1... > kaio-$(date +%F).tar.age

data/ holds the secrets of every stack in clear text in SQLite, and every stack version snapshots them, so a secret you deleted is still in the history. Rotate it at the source after deleting it, and treat the backup as the credential store it is. See Backup and restore.

Finally, pin what runs privileged. A scan hands the Docker socket to the scanner image:

Terminal window
KAIO_TRIVY_IMAGE=aquasec/trivy@sha256:...

KAIO_MCP is off by default, and it is the one setting where the safe choice costs nothing: leave it off on any host where no agent is working, and leave it at read until a write tool is something you want an agent reaching for.

KAIO_MCP is the ceiling for the whole server, and each caller gets that or its own level, whichever is lower. Behind a proxy that names callers, an agent and a person can share one Kaio at different rungs: give the agent a group mapped to read, keep yours on admin, and set KAIO_MCP=write so that no caller at all reaches the destructive tools through /mcp. Without a proxy every caller of a host has the same level, and the separation is per host:

Terminal window
prod-01 KAIO_MCP=read
staging-01 KAIO_MCP=write
lab-01 KAIO_MCP=admin

That maps straight onto an agent’s configuration, one entry per host, named after the host:

.mcp.json
{
"mcpServers": {
"kaio-prod-01": { "type": "http", "url": "https://kaio.example.net/mcp" },
"kaio-lab-01": { "type": "http", "url": "http://lab-01.tailnet-1234.ts.net:8080/mcp" }
}
}

The second guard is the client’s. Tools carry the annotation their rung implies, and a client that acts on it is where “destructive” stops being decoration:

.claude/settings.json
{
"permissions": {
"deny": [
"mcp__kaio-prod-01__kaio_prune",
"mcp__kaio-prod-01__kaio_stack_delete",
"mcp__kaio-prod-01__kaio_volume_delete"
],
"ask": ["mcp__kaio-prod-01__kaio_stack_deploy"]
}
}

The reason to bother is that an agent chooses its next call from text it was given, and that text comes from the host: a container name, a log line, a compose file. None of it is trusted input. write is worth exactly what a curl against /api is worth, because kaio_stack_deploy runs the Compose file it is handed, privileged: true and socket mounts included. There is deliberately no shell tool, and that is the floor, not a sandbox.

The browser guards are separate from the API’s CORS policy and both default to closed:

Terminal window
KAIO_MCP_ALLOWED_HOSTS=kaio.example.net
KAIO_MCP_ALLOWED_ORIGINS=

Unset, KAIO_MCP_ALLOWED_HOSTS accepts loopback only, so a remote agent is refused with 403: that is the variable to set when a handshake fails. Unset, KAIO_MCP_ALLOWED_ORIGINS refuses every request carrying an Origin header, which no agent and no CLI sends, and which is what stops a web page from driving your daemon through /mcp. Leave it unset unless a browser app of your own needs the endpoint. See MCP server for the tools themselves.

Guess nothing. Start with the server’s own reading of its configuration:

Terminal window
sudo sh -c 'set -a; . /etc/kaio/kaio.env; exec kaio-server doctor'
kaio-cli doctor

Expect no danger line, and an exit code of 0. Then each of these has one right answer:

Terminal window
ss -ltnp | grep 8080

The bind address, as the kernel sees it. Expect 127.0.0.1:8080, or your overlay address. 0.0.0.0 means the port is on every interface, whatever your firewall believes: a published Docker port writes its own DNAT rule and walks past ufw.

Terminal window
curl -m 3 http://prod-01.example.net:8080/api/health

From another machine, and expect it to time out. An answer here means the port is reachable without the proxy, and the proxy is decoration.

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' https://kaio.example.net/api/health

Expect 401. Then 200 with the credential:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -u regis https://kaio.example.net/api/health
Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8080/api/stacks/blog/restart \
-H 'Origin: https://evil.test' -H 'Sec-Fetch-Site: cross-site'

On the server itself, expect 403: a page on another site cannot drive the API, even through the loopback.

Terminal window
curl -s -H 'Remote-User: admin' -H 'Remote-Groups: kaio-admins' http://127.0.0.1:8080/api/whoami

From the server itself, expect "user":null: identity headers count only from the proxy. Then from the proxy’s address without the secret, if you set one, expect 403:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -H 'Remote-User: alice' http://127.0.0.1:8080/api/dashboard

That one only holds when the proxy is not on the same host: on one host, a request from the loopback without the secret is local, at KAIO_LOCAL_LEVEL.

Terminal window
curl -s https://kaio.example.net/api/whoami

Through the proxy, after signing in, expect your user, your groups and the level they map to, with "via":"proxy".

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://kaio.example.net/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"curl","version":"0"},"capabilities":{}}}'

401 behind an authenticating proxy, 200 with the credential, 403 if KAIO_MCP_ALLOWED_HOSTS does not name the host you used. Add -H 'Origin: https://evil.test' and expect 403, which is the browser guard doing its job.

A 200 carrying content-type: text/html means something else: /mcp is not routed, KAIO_MCP is off, and what answered is the dashboard’s catch-all. The endpoint being off never looks like a 404.

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' https://kaio.example.net/metrics

401 once KAIO_METRICS_TOKEN is set, 200 with -H "Authorization: Bearer $TOKEN". /metrics changes nothing, but it names every stack and image you run and counts their CVEs, which is a map of what to attack. See Prometheus and Grafana.

Kaio has no users of its own: the names in its audit log are the ones your proxy or your control plane gave it, and a call with no proxy in front is recorded under its source address. There is no rate limiting, and the Host header of /api is not checked, so DNS rebinding is still a way for a browser on the same machine to reach an unproxied loopback port.

Kaio, built by Régis Gaidot