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 tunnel: nothing exposed
Section titled “The tunnel: nothing exposed”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.
ssh -N -L 8080:127.0.0.1:8080 prod-01The 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:
kaio-cli statuskaio-cli tuiAn agent works the same way, with no variable to set on the server:
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/24KAIO_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 and a password
Section titled “Caddy and a password”Caddy terminates TLS, asks for a password, and streams logs and the shell correctly with one directive:
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 }}KAIO_ADDR=127.0.0.1:8080KAIO_TRUSTED_PROXIES=127.0.0.1KAIO_PROXY_SECRET_FILE=/etc/kaio/proxy-secretMint 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:
export KAIO_ADDR="https://regis:$(cat ~/.config/kaio/password)@kaio.example.net"kaio-cli statusRead 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:
{ "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:
KAIO_MCP=readKAIO_MCP_ALLOWED_HOSTS=kaio.example.netSSO for people, a credential for machines
Section titled “SSO for people, a credential for machines”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.
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 }}KAIO_TRUSTED_PROXIES=127.0.0.1KAIO_PROXY_SECRET_FILE=/etc/kaio/proxy-secretKAIO_GROUPS_READ=kaio-viewers,machine-agentKAIO_GROUPS_WRITE=kaio-devs,machine-ciKAIO_GROUPS_ADMIN=kaio-adminsThe 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.netSee 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:
KAIO_MCP=writeKAIO_MCP_ALLOWED_HOSTS=kaio-api.example.netAn overlay network
Section titled “An overlay network”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:
[Service]Environment=KAIO_ADDR=100.84.12.7:8080Environment=KAIO_ALLOWED_NETWORKS=100.64.0.0/10,fd7a:115c:a1e0::/48Without 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:
export KAIO_ADDR=http://prod-01.tailnet-1234.ts.net:8080claude mcp add --transport http kaio-prod-01 http://prod-01.tailnet-1234.ts.net:8080/mcpKAIO_MCP=readKAIO_MCP_ALLOWED_HOSTS=prod-01.tailnet-1234.ts.netWireGuard 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.
A cluster on the overlay
Section titled “A cluster on the overlay”Multiple servers join by token, and the control plane afterwards probes and proxies to each node. On an overlay, join with the overlay address:
kaio-cli join http://prod-01.tailnet-1234.ts.net:8080 --token-stdin \ --advertise http://prod-02.tailnet-1234.ts.net:8080Three things worth doing once the cluster exists:
- Treat the control plane as the union of its nodes.
/api/nodes/{name}/proxyreaches 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 leaveon 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/exportanswers 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:
KAIO_ADDR=0.0.0.0:8080curl -s -o /dev/null -w '%{http_code}\n' http://prod-02.example.net:8080/api/dashboard403 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.
Harden the host and the service
Section titled “Harden the host and the service”The shipped unit already drops privileges. These directives go further, and they are a drop-in so an upgrade keeps them:
[Service]UMask=0077ProtectSystem=strictReadWritePaths=/var/lib/kaioProtectProc=invisibleProtectKernelTunables=yesProtectKernelModules=yesProtectKernelLogs=yesProtectControlGroups=yesRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6RestrictSUIDSGID=yesLockPersonality=yessystemd-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/24Give 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:
chmod 700 /var/lib/kaiotar -C /var/lib -cf - kaio | age -r age1... > kaio-$(date +%F).tar.agedata/ 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:
KAIO_TRIVY_IMAGE=aquasec/trivy@sha256:...The agent surface
Section titled “The agent surface”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:
prod-01 KAIO_MCP=readstaging-01 KAIO_MCP=writelab-01 KAIO_MCP=adminThat maps straight onto an agent’s configuration, one entry per host, named after the host:
{ "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:
{ "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:
KAIO_MCP_ALLOWED_HOSTS=kaio.example.netKAIO_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.
Prove it
Section titled “Prove it”Guess nothing. Start with the server’s own reading of its configuration:
sudo sh -c 'set -a; . /etc/kaio/kaio.env; exec kaio-server doctor'kaio-cli doctorExpect no danger line, and an exit code of 0. Then each of these has one
right answer:
ss -ltnp | grep 8080The 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.
curl -m 3 http://prod-01.example.net:8080/api/healthFrom another machine, and expect it to time out. An answer here means the port is reachable without the proxy, and the proxy is decoration.
curl -s -o /dev/null -w '%{http_code}\n' https://kaio.example.net/api/healthExpect 401. Then 200 with the credential:
curl -s -o /dev/null -w '%{http_code}\n' -u regis https://kaio.example.net/api/healthcurl -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.
curl -s -H 'Remote-User: admin' -H 'Remote-Groups: kaio-admins' http://127.0.0.1:8080/api/whoamiFrom 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:
curl -s -o /dev/null -w '%{http_code}\n' -H 'Remote-User: alice' http://127.0.0.1:8080/api/dashboardThat 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.
curl -s https://kaio.example.net/api/whoamiThrough the proxy, after signing in, expect your user, your groups and the level
they map to, with "via":"proxy".
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.
curl -s -o /dev/null -w '%{http_code}\n' https://kaio.example.net/metrics401 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.
What none of this fixes
Section titled “What none of this fixes”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