Dolomite, an operating system for a small team
A multi-user NixOS we own end to end, so we can afford to be modern, self-built, and correctness-first.
Dolomite is the operating system my colleagues and I run, and a foundation other people build on. At its center is one NixOS flake, but it is not only an internal fleet config: colleagues keep their own flakes that pull Dolomite in as an upstream dependency and build their systems on top of it.
From one repository and one lockfile it configures a wide spread of targets across x86_64 and aarch64: desktops, laptops, a build host, a small AI cluster, a handful of VPSes, a cluster of Raspberry Pis, a camera box, embedded systems, and machines that do not run NixOS at all, where Nix builds and manages the configuration for a system it does not itself boot. It also forms the basis of the HyperPrint project's firmware.
Plenty of people run NixOS fleets; a flake and a lockfile are ordinary. One fact makes the difference here: we own the whole system and automate its upkeep, so we can afford to be modern, self-built, and correctness-first where a more cautious setup reaches for the defaults. That affords a set of deliberate departures from stock NixOS:
- The security boundary is the container's namespace isolation, not the Unix user ID.
- No home-manager.
- One high-level, composable, typed API, so no one has to fork the Dolomite repo to run Dolomite OS.
- Correctness-first: as much logic as possible in safe Rust and nushell.
- Monorepo-first, breaking components out into their own repos only once they grow big enough to earn it, which lets me tweak three of them at once without ceremony.
- Security by default without giving up convenience: remote unlocking, signed kernel and initrd, disks encrypted even where a machine does not strictly need it.
- Modern over compat: we control every layer, so we can change five of them at once; bash and fish stay only as legacy shells for what has not caught up yet.
The problem it exists to solve
Dolomite is older than the agentic-coding wave. It existed first as a plain multi-user NixOS: a declarative system a handful of people shared, built and maintained by hand. When coding agents became good enough to do real operational work, I adapted Dolomite to run them and take on some of the maintenance burden of a fleet this size.
That adaptation is what shaped the security design. An agent doing useful work needs to read and write a project directory, run a compiler, and occasionally do something that has to happen outside its own confinement: push a commit, build a system closure, restart a service.
Every one of those escapes is a place where a prompt injection turns into code execution on the host. The naive options are both bad. Give the agent a normal shell and you have given it your SSH keys. Give it nothing and it cannot do the work.
Dolomite's answer is a narrow, typed, auditable channel through the sandbox wall, and a threat model that is explicit about what it does not cover. That channel is what let me add agents to a system built for people without loosening what the people already relied on.
The security boundary is the namespace, not the user ID
The security boundary is the container's namespace isolation, not the Unix user ID.
Two mechanisms enforce it: a sandbox and a gate.
nixpak and rungate
Applications that handle untrusted input (browsers, chat clients, coding
agents) run inside nixpak, a bubblewrap-based sandbox. The container gets
its own PID namespace, its own mount namespace, and a /proc that shows only
its own process tree. What it can reach is exactly what is bind-mounted in, and
nothing else.
For the coding agent, that set is deliberately small:
| Access | Paths |
|---|---|
| Read-write | the project directory, the agent's own config and state, an isolated per-app /tmp, the daemon socket |
| Read-only | the system software closure, the Nix store, git config, shell config, the agent's authentication secret |
| Absent | ~/.ssh and SSH_AUTH_SOCK, the global /tmp, the Nix daemon socket, other home directories, all cryptographic material |
The second half is rungate: a daemon that runs outside the sandbox, as the
same Unix user, and executes whitelisted commands on the sandboxed client's
behalf. Inside the container, nix is not the Nix binary; it is a symlink to
the rungate client, which forwards a structured request over a Unix socket.
Every command rungate exposes has a typed schema. nix build will accept a
flake reference and will accept --override-input, but only with a path that
canonicalises to somewhere under my projects directory. systemctl unit names
must match ^[a-zA-Z0-9@._:-]+$. git push is available; git push --force is
not, and the template hard-sets core.hooksPath=/dev/null. Values are passed as
argv elements. There is no shell anywhere in the path: no interpretation, no
glob expansion, no way to chain a second command onto the first.
Commands are also tiered. Most are unattended. A few (nix flake update,
nix store gc, anything that starts or stops a unit) are attended, and block
until I approve them on a separate admin socket.
Everything not on the whitelist is refused.
Why "same user ID" is the wrong objection
The most common criticism of this design is that rungate runs as the same user as the sandboxed process, so the sandbox could do whatever rungate does, directly.
It cannot, and the reason matters. Same UID does not mean same namespace. From
inside the container there is no route to the host's process table, no way to
signal the daemon, no /proc/<host-pid>/environ to read, no access to files
that were never bind-mounted, including the daemon's own configuration, the
other clients' secrets, and the audit log. The attack surface is precisely the
enumerable list: the socket, the client's own secret, the working directory, and
the wire protocol.
The corollary is the part people like less. Rungate cannot defend against a compromised unsandboxed process running as me. Such a process is me, as far as the kernel is concerned; it can read every file I own, ptrace my processes, overwrite rungate's config, replace its binary, or run the gated commands itself. If that is your threat, rungate is not your control, and no amount of socket permissions or token tiers would make it one.
I state this plainly in the design docs because the alternative is a security review that files "any same-UID process can do X" as a finding, without ever demonstrating that X is reachable from inside the container. That is not a finding. It is a category error, and it wastes the reviewer's time and mine.
The attack surface nobody puts in the diagram
Here is the part I find hard, and the part I would want reviewed first by anyone reading this.
The sandbox has write access to the working directory. When rungate executes a command on the client's behalf, that command runs in that same working directory, outside the sandbox. So the client controls part of the input to a process it is not supposed to be able to influence.
Git hooks are the obvious instance: write .git/hooks/pre-push, ask rungate to
push, and your script runs unconfined with full access to the SSH keys the
sandbox was specifically denied. Build scripts are the same shape: make,
cargo, npm run all execute instructions read from files the client can
rewrite. So are rc files: .envrc, .npmrc, .cargo/config.toml, anything a
tool silently picks up from the tree it is pointed at.
This is not a bug in rungate. It is an inherent property of executing a command in a directory the caller controls. The command template decides which binary runs and with which flags; it does not decide what that binary chooses to read off the disk.
There is no general fix, so the mitigation is per-command and the discipline is
procedural: every new command template has to answer, in writing, "which files
in the working directory can influence this, and how is that neutralised?"
Disable the hooks. Pin the config. Set a clean HOME. Or decline to expose the
command.
The network is the firewall
Machines are meshed with WireGuard, generated by a fork of wirenix from a single shared ACL: subnets, peers, groups, and which groups may reach which. Each machine evaluates the same ACL and derives only its own interface config.
Services then bind directly to their WireGuard address rather than to
127.0.0.1 or 0.0.0.0:
services.open-webui = {
host = thisMachine.intranetIP4; # 10.10.0.6
port = 8083;
};
That one line is the access control. There are no iptables rules for this
service because there is nothing to filter: if you are not an authenticated
WireGuard peer, the address does not exist for you. A machine on the same
physical LAN is no closer to it than one on the other side of the internet. And
the exposure model is legible from the config: thisMachine.intranetIP4 reads
as internal, 0.0.0.0 reads as "this needs a firewall audit," and you can grep
for the second one.
The trade-off is real: services now depend on WireGuard being up, which has to be enforced through systemd ordering rather than assumed.
Secrets are sops-nix with age, and the age keys are derived from each machine's SSH host key, so provisioning a new machine's identity and provisioning its ability to decrypt are the same step, and there is no separate key to lose.
No home-manager
Almost all of Dolomite's configuration lives at one level: the global, declarative NixOS layer every machine evaluates. It does not use home-manager, and that is deliberate, for two technical reasons that come before any question of taste.
First, home-manager is an eval-performance hazard once you have many users with near-identical environments, or tooling that has to be filled out from the global eval context. Each per-user generation re-evaluates a large module set that barely differs from the next person's, and the cost scales with the number of people on the box.
Second, it forces per-user nix-daemon access. Handing every user their own path to the daemon widens the surface the security design works to keep narrow, and in a system built to run coding agents that is a compromise I will not make.
There is a legibility argument underneath those, secondary to them: when several people each carry a per-user layer, the layers drift from the global config and from each other, and "why is my shell different on this host" turns into an archaeology dig. Keeping everyone on the same global layer keeps a shared box legible.
One composable, typed API
Dolomite is meant to be built on. Its interface is flake-parts throughout, composed rather than monolithic, on the principle that no one should have to fork the Dolomite repo to run Dolomite OS. Colleagues pull it in as an upstream dependency and assemble their own systems from its modules, and the flake exposes high-level, customizable options instead of asking you to reach into its internals.
The same instinct runs down to files and state. Where stock NixOS reaches for a pile of systemd tmpfiles rules to lay down a file or a directory, Dolomite gives files, folders, and state their own typed APIs for creating and maintaining them: state management you can read, reverse, and reason about, in place of fire-and-forget side effects. It is the same instinct as the merge-with-provenance idea in the next section, which wants a typed, reversible, provenance-aware record of what the system put where.
Merging into config files, not owning them
The usual NixOS and home-manager pattern is that a config file belongs to the system. You declare its contents in Nix, the file is generated whole, and anything a user writes into it by hand is ignored or overwritten on the next rebuild. That model is clean, and for a single-purpose appliance it is the right one. For a box that several people use interactively, it fights the users.
Dolomite takes the other approach. Where it can, it merges its settings into a config file and leaves the rest of the file alone, so a user's hand edits and Dolomite's declared settings live in the same file. Someone can tweak their editor or shell config without Dolomite stamping the change back out on the next switch, and Dolomite can still assert the handful of settings it needs to own.
That flexibility costs something real: a clean source of truth. When Dolomite owns a file outright, the Nix expression is the file, and you always know what is in it and why. Merging gives that up, and several problems follow directly:
- You cannot cleanly separate what Dolomite put in a file from what the user added. The file is a blend with no labels.
- Removing or renaming a setting Dolomite used to write becomes a guess. If a key is present, was it Dolomite's, and safe to migrate or drop, or the user's, and not yours to touch? Without a record you cannot tell, so stale settings tend to linger.
- The result drifts. Two rebuilds from the same declared inputs can produce different files if a user edited in between, and any ordering or idempotency bug in the merge logic surfaces as settings that move or reappear.
There is a design idea I think would address most of this, and I want to be plain that it is an idea rather than something Dolomite does today. The idea is to have Dolomite record a delta every time it moves a config from one version of its declared state to the next: the exact settings it added, changed, or removed on that transition. That delta is a provenance record. With it, Dolomite can look at any line in a merged file and know whether it wrote that line or a human did, which is the knowledge the problems above are missing. Removing a setting becomes safe because you can show it was yours, and drift becomes detectable because you can compare the file against what your own deltas say should be there.
What that would buy is reach. An app with a native layering mechanism, a conf.d directory or an include or a drop-in, barely needs merging at all: you hand it your own file and never touch the user's. The hard case is the app whose whole configuration is one monolithic file with no include mechanism, where coexisting with the user means editing that single file in place. That case is everywhere once Dolomite reaches past NixOS. Embedded targets and non-NixOS machines rarely offer whole-file ownership or a conf.d directory to drop into, so configuring them means editing files their own tooling also writes. A provenance record is what would let the merge approach work there too. That is the case I would build it for, and for now it stays on the design side of the line rather than the shipped side.
Correctness-first: Rust and nushell
As much of Dolomite's logic as possible lives in safe Rust and nushell rather than bash. The rule I follow is nushell while something is a script, and safe Rust once its correctness starts to matter. Two components have already made that trip: the nixpak launcher went from Go to Rust, as its own write-up covers, and the deploy tool went from an 800-line nushell script to Rust. rungate's typed command schemas are the same idea pointed at a different problem, putting the constraints in a type where a comment would otherwise be asking the next person to be careful.
manage, the deploy tool
The tool that drives all of this is called manage: a single front-end to the
whole machine lifecycle that owns build, provision, deploy, and secrets as one
correctness-first tool. Like the nixpak launcher, it made the nushell-to-Rust
trip once its correctness started to matter, and it reaches targets colmena,
nixops, and deploy-rs cannot, including non-NixOS machines and ones with no SSH
at all. It has its own write-up.
Automate everything; trust by construction
The rule is to automate anything that takes human time, as long as it can be
done safely and holds up under failure. VM-based unit tests and cargo xtask
catch bugs before they reach production. When an LLM does operational work it
operates on a codebase that can be inspected before deployment, instead of
through a live root SSH session, and the codebase is the running record of
exactly how the system works, so any state can be reapplied, debugged, or
audited from it.
The same principle points at what the system is built from. Standing on NixOS makes it cheap to stop trusting heterogeneous upstreams: rather than ship someone's Orange Pi image, build our own; compile OpenWRT and thingino from source as well; let agents read the source while build servers prove the shipped binary matches it. None of that balloons developer time, which is the only reason it is worth doing at all.
Modern over compat, with a safety net
Because we control every layer, we can move all of them at once. A change that would mean coordinating across five teams elsewhere is five edits in one tree here, so Dolomite runs modern, fast-moving components where a more conservative fleet would pin the old ones for compatibility. bash and fish survive only as legacy shells, kept for the things that have not caught up to nushell yet.
Moving that fast stays safe because the safety net sits right underneath it. The
VM tests and cargo xtask checks catch a bad change before it ships, and
manage rolls a failed activation back instead of stranding the machine. A
mistake gets caught and reversed before it reaches production.
Where it bites back
A design like this fails in unglamorous places.
The sandbox has a resolve strategy that binds the entire transitive Nix closure
of an application, one --ro-bind pair per store path. For a large app that is
thousands of arguments, and execve caps the combined size of argv and
envp. Past the limit the launch dies with E2BIG, not degraded, not slow,
gone, with an error message that tells you nothing about bind mounts. The
fix is to hand bubblewrap its arguments over a file descriptor instead of the
command line, compute the kernel's budget at runtime, and define a fallback
ladder so that an over-budget launch steps down through cheaper strategies
rather than failing. Until that landed, the strategy stayed opt-in with a
comment explaining why.
The design is the easy part; everything hard about Dolomite is in making it survive the kernel.
Status
Dolomite is currently semi-open: the design documents circulate, the ideas are not secret, but the tree still carries enough of my own topology that I have not published it. The intent is a full open-source release once the machine schema and the secrets layout are cleanly separable from the fleet they describe.
Until then, if you are building something in this shape, the advice is the same across every pillar: decide where your boundary sits, write down what it does not cover, and refuse a finding that assumes a different one. That holds for the sandbox, and it holds for every other choice here that departs from the defaults.