ADR-0010: kaged supports per-user and system-wide deployment, equally
- Status: Accepted
- Date: 2026-05-21
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
Earlier ADRs assumed a single deployment shape: kaged installed system-wide, running as a dedicated kaged system user, with state under /var/lib/kaged, fronted by an OAuth sidecar, reachable via a tunnel. That shape works for shared infrastructure (a homelab box several people use, a self-hosted operator console in a small team).
It does not work cleanly for the most common case: an individual operator running kaged on their own laptop or workstation, for their own use, without root, without a shared system user. For that operator, the system-wide model is unnecessary friction:
- They don't want to
sudoto install kaged. - They don't want a system service running for them when they're logged out.
- They don't want to set up an OAuth sidecar to reach their own daemon from their own browser.
- They want their state under
$HOME, version-controlled by their own dotfiles if they like.
A separate but adjacent realization: projects should be shareable. An operator who creates a kaged project should be able to give the project to another operator, on a different machine, and have it work. That's not possible if the daemon's deployment model is one-size-fits-all and project files have to assume specific paths, users, or service shapes.
The question is load-bearing because it determines:
- Where state lives on disk (ADR-0005).
- What the auth model looks like in the absence of a sidecar (ADR-0007).
- Which systemd unit the operator installs.
- Whether project files can refer to operator-specific paths (they can't, if portability matters).
Decision
kaged supports two deployment modes as equal first-class citizens: per-user (the operator's own daemon, running under their UID, no root required) and system-wide (a shared kaged instance running as a dedicated system user). Both modes use the same binary, the same code paths, and the same project file format. Mode is selected at install/startup by the operator, not by the project.
Concretely:
Per-user mode
- Binary location: anywhere on the operator's
PATH. Often~/.local/bin/kagedor/usr/local/bin/kaged. - State location:
$XDG_DATA_HOME/kaged(default:~/.local/share/kaged). - Config location:
$XDG_CONFIG_HOME/kaged/config.toml(default:~/.config/kaged/config.toml). - Process: the operator's own UID. No
kagedsystem user. - Service:
~/.config/systemd/user/kaged.serviceenabled viasystemctl --user enable --now kaged. - Default bind:
127.0.0.1:<random-free-port>(or a fixed port the operator configures). - Default auth: loopback + cookie-bound nonce (see ADR-0007 amendment). No sidecar required.
- Tunnel/remote access: optional; the operator adds Cloudflare Tunnel + OAuth sidecar themselves the same way as in system-wide mode.
System-wide mode
- Binary location:
/usr/local/bin/kaged. - State location:
/var/lib/kaged(as before). - Config location:
/etc/kaged/config.toml. - Process: a dedicated
kagedsystem user. - Service:
/etc/systemd/system/kaged.serviceenabled viasystemctl enable --now kaged. - Default bind:
127.0.0.1:7777. - Default auth: sidecar contract (as ADR-0007 originally described).
- Tunnel/remote access: the blessed deployment with Cloudflare Tunnel + OAuth sidecar.
Mode detection
The daemon detects mode at startup by inspecting:
- Whether
KAGED_HOMEis set explicitly (operator's call wins). - Whether the daemon is running under UID 0 or as the
kageduser (system-wide indicators). - Whether
$XDG_DATA_HOME/kagedor~/.local/share/kagedexists (per-user indicators). - Falls back to per-user mode if ambiguous.
The detection is logged at startup so operators can see what was chosen.
What does NOT differ between modes
These are the same regardless of mode:
- The project DSL format. Per ADR-0011, the DSL contains nothing operator-local. A project file from a per-user kaged works on a system-wide kaged and vice versa.
- The HTTP+WS API surface. Same endpoints, same shapes.
- The plugin SDK. Plugins target the same JSON-RPC contract.
- The sandbox mechanism. bwrap works the same as a regular user as it does as a system user (modern user-namespace kernels). Per ADR-0009, bwrap is the v0 cage mechanism.
- The CLI commands.
kaged status,kaged dsl validate, etc. behave identically.
The mode is a deployment concern, not a behavior concern. Operators reading project documentation should not have to know which mode it was written for.
Consequences
What this commits us to
- Two systemd units shipped as documentation: a system unit and a user unit. Both maintained.
- XDG Base Directory Specification compliance for per-user paths. This is the operator-friendly convention on Linux; following it means dotfiles tools and disk-usage analyzers work as expected.
- Mode-aware logging and error messages. "Where's my data?" has a different answer in each mode; the daemon must surface the active mode in
kaged statusand in startup logs. - Documentation that walks the operator through choosing a mode. Most likely a one-page "choose your deployment" doc that points at the two install paths.
- Test matrix doubles in the deployment-touching layer. Storage paths, systemd integration, and auth defaults all get tested in both modes.
What this forecloses
- No
root-only privileges anywhere in the daemon. If we add a feature that requires root (e.g. binding port 80 directly, writing to/etc), it's not available in per-user mode. Per-user is the operator's UID; the operator can't escalate from inside kaged. - No global system state that per-user mode can't replicate. If a system-wide deployment carries something a per-user can't, the project file becomes mode-coupled and shareability dies. So: no global state.
- No "the daemon needs to know which mode every other daemon is in." Cross-daemon coordination (a v2 concern) cannot assume one mode or the other. The protocol stays mode-agnostic.
What becomes easier
- First-run UX for individual operators. Install via
cargo install-style command (when we have one) orcurl | sh(when we have one), runkaged start, open the printed URL. No sudo, no sidecar. - Shareability of projects. Combined with ADR-0011, a project file is portable from one operator's per-user kaged to another's system-wide kaged.
- Trying kaged on a laptop. No reason to install a daemon and a sidecar just to evaluate the product.
- CI and dev. Tests can spin up a per-user kaged in a temp directory without root, without systemd.
What becomes harder
- Two filesystem layouts to document, test, and maintain. Some bugs will manifest in only one mode. We carry that.
- Two auth defaults to document. Per-user is loopback-only by default; system-wide expects a sidecar. The "should I have a sidecar?" question has two right answers depending on mode.
- Per-user mode complicates "shared homelab" stories. Two operators on the same Linux box each running their own kaged is supported (each is just a UID; XDG paths don't collide), but the social setup of "we share a Pi" now requires picking the model deliberately rather than defaulting.
- systemd
--userunits have their own gotchas.lingeringmust be enabled for the user if they want kaged to run when logged out (loginctl enable-linger <user>). Documented; not invisible.
Alternatives considered
Alternative A — System-wide only
Why tempting: Less surface area. One filesystem layout. One auth path.
Why rejected: Drives away the largest population of likely operators — individuals using kaged for their own projects on their own machines. Forces them to deal with system users and sidecars they don't need. Violates the manifesto's "self-hosted by an individual operator" framing.
Alternative B — Per-user only
Why tempting: Simpler than two modes.
Why rejected: Excludes the "team homelab" and "small group running a shared kaged" cases. These are real and have different security needs (audit log per-operator, RBAC eventually) than per-user. A system-wide deployment is also the right shape for some kinds of hosted (operator-self-hosted, not SaaS) deployments where kaged should outlive any one operator login session.
Alternative C — Pluggable filesystem layout, no opinions
Why tempting: "It's just paths in the config."
Why rejected: Without opinionated defaults per mode, the operator has to make N micro-decisions about paths, users, units, auth — each compounding the install effort. The two named modes are useful precisely because they bundle decisions. Operators who want full custom can still set every path explicitly; the defaults exist for everyone else.
Alternative D — Single mode, but the install script branches
Why tempting: Internally only one daemon model; the difference is just where files live and which systemd unit ships.
Why rejected: That's almost what we're doing, but framing it as "two modes" makes the doc, the audit log, the status output, and the test matrix all clearer. The branches in code are small; the documentation clarity is large.
References
- ADR-0005 and its amendment — storage paths per mode
- ADR-0007 and its amendment — per-user auth model
- ADR-0008 and its amendment — local vs project plugin scoping
- ADR-0011 — project portability, the other half of the realization
docs/specs/daemon.md— mode detection, paths, systemd unitsdocs/specs/local-config.md— operator-local config (model aliases, plugin store)- XDG Base Directory Specification: https://specifications.freedesktop.org/basedir-spec/
- systemd user units: https://www.freedesktop.org/software/systemd/man/systemd.service.html#User-Specific%20systemd%20Units
- Original discussion: design conversation with colleagues, 2026-05-21