I Run a Bunch of OpenClaw Bots on NixOS. None Have My Keys.
Look, I've been running OpenClaw bots for months. Five of them, all on one machine. They share an Ollama backend, each has its own Telegram bot token, its own state directory, its own systemd user. They triage email, manage my calendar, track tasks, watch the home lab, transcribe voice notes.
And not one of them has my Google service account key. Or my Brave API key. Or my Sonarr token. Or my Anna's Archive cookie. The credentials live in dedicated proxy services. The agents talk to those proxies through Unix sockets. The agents themselves have nothing worth stealing.
This post is the NixOS-specific companion to "Your OpenClaw Doesn't Need Your API Keys". Read that first if you want the pattern itself. This one is the actual code.
Why NixOS Is the Right Place for This
You can implement the proxy pattern on any Linux box. You'd write a systemd unit by hand, add a user with useradd, set socket permissions with tmpfiles.d, drop a config in /etc. It works.
But every one of those steps is something you can forget. The proxy ends up running as root because you didn't get around to making the user. The socket ends up world-writable because you typoed the mode. The credential ends up in the agent's environment because the systemd unit and the credential file drifted apart over time.
NixOS makes the boundary declarative:
users.users.<proxy>— dedicated unprivileged user, can't forget to create itsystemd.services.<proxy>.serviceConfig— hardening as plain attrs, applied consistently across every restartsystemd.tmpfiles.rules— socket directory with correct mode and owner, recreated on every bootage.secrets.<name>.owner— credential decrypted by agenix and chowned to the proxy user, never readable by the agentenvironment.etc."<bot>/exec-approvals.json"— the OpenClaw exec allowlist generated from the module, not hand-edited- Build-time validation — bad config fails
nix build, not at runtime
If it builds, the boundary holds. That's the deal.
The Layout
Everything in my justin-nix repo splits into three layers:
nixos/
├── bot-common.nix # shared building blocks
├── bot-browser-fetch.nix # headless browser proxy + client CLI
├── bot-google-api.nix # Google API proxy + client CLI
├── bot-arr-api.nix # Sonarr/Radarr proxy + client CLI
├── bot-annas-archive.nix # Anna's Archive proxy + client CLI
├── bot-bridge.nix # inter-bot messaging
├── basecamp.nix # Basecamp bot — composes everything
├── erdos.nix # Erdos bot — different composition
└── archie.nix # Archie bot — yet another composition
Each bot-*.nix file in the middle layer defines both a server (the proxy that holds creds) and a client CLI (what the agent calls). Each bot composition file at the bottom layer wires them together with the right systemd users, secrets, and exec allowlists.
Layer 1: bot-common.nix — The Shared Building Blocks
The heart of the pattern. bot-common.nix exports a set of functions and attrs that every bot reuses:
{pkgs, lib, openclaw-nix, llm-agents}: let
openclawPkg = openclaw-nix.packages.x86_64-linux.openclaw-gateway;
in rec {
inherit openclawPkg;
# systemd hardening applied to every bot gateway
hardeningConfig = {
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK"];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = false; # Node.js JIT
SystemCallArchitectures = "native";
SystemCallFilter = ["@system-service" "~@resources"];
CapabilityBoundingSet = "";
AmbientCapabilities = "";
UMask = "0077";
MemoryMax = "2G"; # cap blast radius
};
# Shell commands available to every bot via OpenClaw's exec tool.
# Bot-specific tools (git, curl-for-internal-only, etc.) added per-bot.
baseExecCommands = [
"bash" "sh" "ls" "cat" "grep" "echo" "find"
"mkdir" "cp" "mv" "rm" "chmod" "date"
"head" "tail" "wc" "sort"
"openclaw-safe" # scoped wrapper around openclaw CLI
"google-api" # credential-isolated Google API client
"browser-fetch" # headless browser via proxy socket
"socat" # needed by the proxy CLIs
"transcribe" # local whisper-server client
];
# Build the JSON allowlist OpenClaw reads from exec-approvals.json.
# `/nix/store/*/bin/<cmd>` pattern means even path manipulation
# can't smuggle in a different binary with the same name.
mkExecApprovals = extraCommands:
builtins.toJSON {
version = 1;
defaults = {security = "full"; ask = "off"; askFallback = "off";};
agents.main = {
security = "full";
ask = "off";
allowlist = map (cmd: {pattern = "/nix/store/*/bin/${cmd}";})
(baseExecCommands ++ extraCommands);
};
};
# ... mkOpenclawConfig, mkPreStart, mkTmpfilesRules,
# validateOpenclawJson, etc.
}
Notice what's not in baseExecCommands: curl, wget, ssh, nc, python, node, npm, pip. The agent has none of those. It has shell utils, OpenClaw's own scoped wrapper, and the proxy CLIs. That's it.
This pairs with "Your Agent Doesn't Need curl" — same idea, made declarative.
Layer 2: A Proxy File (bot-browser-fetch.nix)
The simplest worked example. The whole file is ~230 lines; this is the structural part:
{pkgs}: let
# Server: runs as browser-fetch user. Listens on a Unix socket,
# reads JSON requests, renders pages with headless Chromium,
# returns markdown.
browserFetchProxy = pkgs.writeShellScriptBin "browser-fetch-proxy" ''
set -euo pipefail
SOCKET="/run/browser-fetch-proxy/fetch.sock"
do_handle() {
read -r REQUEST
URL=$(${pkgs.jq}/bin/jq -r '.url // empty' <<< "$REQUEST")
# Validate scheme + RFC1918 + optional allowlist
validate_url "$URL" || return
HTML=$(${pkgs.coreutils}/bin/timeout 45s \
${pkgs.chromium}/bin/chromium \
--headless=new --no-sandbox --dump-dom \
"$URL" 2>/dev/null)
MARKDOWN=$(printf '%s' "$HTML" \
| ${pkgs.readability-cli}/bin/readable --quiet --html \
| ${pkgs.pandoc}/bin/pandoc -f html -t markdown --wrap=none)
${pkgs.jq}/bin/jq -nc --arg c "$MARKDOWN" '{content: $c}'
}
case "''${1:-}" in
--serve)
rm -f "$SOCKET"
exec ${pkgs.socat}/bin/socat -t60 \
UNIX-LISTEN:"$SOCKET",fork,mode=0666 \
EXEC:"$0 --handle"
;;
--handle) do_handle ;;
esac
'';
# Client: runs as the bot user. No Chromium, no network tools.
# Just socat + jq.
browserFetch = pkgs.writeShellScriptBin "browser-fetch" ''
SOCKET="/run/browser-fetch-proxy/fetch.sock"
URL="$1"
${pkgs.jq}/bin/jq -nc --arg url "$URL" '{"url": $url}' \
| ${pkgs.socat}/bin/socat -t60 - UNIX-CONNECT:"$SOCKET" \
| ${pkgs.jq}/bin/jq -r '.content'
'';
in {inherit browserFetchProxy browserFetch;}
Two packages. The server holds Chromium. The client holds nothing. Both are imported by name in the bot composition file.
Layer 3: Wiring It Up (basecamp.nix)
Here's the actual composition for Basecamp, the bot that talks to Google APIs. I'm showing the security-relevant parts; full file is ~600 lines.
{config, pkgs, lib, openclaw-nix, llm-agents, ...}: let
bot = import ./bot-common.nix {inherit pkgs lib openclaw-nix llm-agents;};
google = import ./bot-google-api.nix {inherit pkgs;};
browser = import ./bot-browser-fetch.nix {inherit pkgs;};
stateDir = "/var/lib/basecamp";
proxyStateDir = "/var/lib/google-proxy";
basecampConfig = bot.mkOpenclawConfig {
telegramAllowFrom = [/* user ids */];
workspaceDir = "${stateDir}/.openclaw/workspace";
/* ... */
};
in {
# --- The agent user (no creds, no Chromium, no SA key) ---
users.users.basecamp = {
isSystemUser = true;
group = "basecamp";
home = stateDir;
createHome = true;
};
users.groups.basecamp = {};
# --- The proxy user (owns SA key, agent can't read it) ---
users.users.google-proxy = {
isSystemUser = true;
group = "google-proxy";
home = proxyStateDir;
createHome = true;
};
users.groups.google-proxy = {};
Two users. The kernel enforces the boundary. The agent user is in group basecamp. The SA key is owned by google-proxy:google-proxy at mode 0400. The agent literally cannot read it.
The Secret Itself: agenix With the Right Owner
The credential file is encrypted at rest with agenix and decrypted at runtime by systemd, but the critical detail is owner = "google-proxy":
age.secrets.basecamp-google-sa-key = {
file = ./secrets/basecamp-google-sa-key.age;
mode = "0400";
owner = "google-proxy"; # NOT "basecamp"
group = "google-proxy";
};
This is the single most important line in the whole setup. If you set owner = "basecamp" here, the entire proxy pattern collapses — the agent could read the SA key directly. With owner = "google-proxy", only the proxy service can read it, and the agent has to go through the Unix socket to use it.
The Proxy systemd Service
systemd.services.basecamp-google-proxy = {
description = "Basecamp Google API proxy (credential isolation)";
after = ["network-online.target"];
wants = ["network-online.target"];
wantedBy = ["multi-user.target"];
environment = {
GOOGLE_SA_KEY_PATH = config.age.secrets.basecamp-google-sa-key.path;
GOOGLE_SA_SUBJECT = "basecamp@newideamachine.com";
};
serviceConfig = {
User = "google-proxy";
Group = "google-proxy";
# SupplementaryGroups lets socat create the socket with group=basecamp
SupplementaryGroups = ["basecamp"];
RuntimeDirectory = "basecamp-google-proxy"; # /run/basecamp-google-proxy
RuntimeDirectoryMode = "0755";
ExecStart = "${google.googleApiProxy}/bin/google-api-proxy --serve";
Restart = "always";
RestartSec = 5;
# Hardening — applied per-service, declared in one place
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectProc = "invisible";
RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_UNIX"];
RestrictNamespaces = true;
UMask = "0077";
};
};
The proxy talks to oauth2.googleapis.com over TLS to mint tokens, so it needs AF_INET/AF_INET6. The agent's gateway service, by contrast, can use a stripped RestrictAddressFamilies = ["AF_UNIX"] in setups where it doesn't need direct network access — egress only happens through proxies.
The Socket Directory: tmpfiles With Correct Ownership
The socket lives in /run/basecamp-google-proxy/api.sock. The directory is created via RuntimeDirectory above. The socket file itself is created by socat with group basecamp (via SupplementaryGroups) and mode 0660, so only the basecamp user — the agent — can connect.
For longer-lived state, bot-common.nix exports mkTmpfilesRules:
systemd.tmpfiles.rules = bot.mkTmpfilesRules {
inherit stateDir;
user = "basecamp";
group = "basecamp";
extraRules = [
"d ${proxyStateDir} 0700 google-proxy google-proxy - -"
];
};
Mode 0700 on the proxy's state dir means even other system users can't traverse into it. The agent can't accidentally find a forgotten token cache or log file with a credential leak.
The Exec Allowlist, Generated From the Module
OpenClaw's exec tool reads exec-approvals.json at startup. Hand-editing that file means every change is a chance for drift. Generating it from the module means the allowlist is whatever your Nix says, every time:
environment.etc."basecamp/exec-approvals.json" = {
mode = "0640";
group = "basecamp";
text = bot.mkExecApprovals ["jq" "socat"];
};
That call produces an allowlist with the baseExecCommands from bot-common.nix plus jq and socat. The full list is short, intentional, and identical across rebuilds. curl is not there. wget is not there. python is not there.
Build-Time Validation
The other line that turned me into a believer:
environment.etc."basecamp/basecamp.json" = {
mode = "0640";
group = "basecamp";
source = bot.validateOpenclawJson basecampConfig;
};
validateOpenclawJson wraps the bot's config in a Nix derivation that runs OpenClaw's schema check at build time. If the config is malformed — wrong compaction mode, missing workspace, invalid tool list — nix build fails. The bad config never reaches a running gateway. No "deploy and crash-loop." No "discover at 3am that the prompt change broke validation."
OpenClaw's web docs disagree with the actual schema often enough that this catch saves me an incident a month. Build-time refusal beats runtime debugging.
The Gateway Itself
Putting it all together, the agent's systemd service looks like this:
systemd.services.basecamp-gateway = {
description = "Basecamp OpenClaw gateway";
after = [
"network-online.target" "ollama.service"
"basecamp-google-proxy.service" # proxy must be up first
"browser-fetch-proxy.service"
];
wants = [
"network-online.target" "ollama.service"
"basecamp-google-proxy.service"
"browser-fetch-proxy.service"
];
wantedBy = ["multi-user.target"];
restartTriggers = bot.mkRestartTriggers {
inherit config;
etcPrefix = "basecamp";
};
serviceConfig = {
User = "basecamp";
Group = "basecamp";
ExecStart = bot.mkGatewayExec 3001;
ExecStartPre = bot.mkPreStart {
inherit stateDir;
etcPrefix = "basecamp";
};
Restart = "always";
ReadWritePaths = [stateDir];
} // bot.hardeningConfig // bot.gatewayUnitConfig;
};
The two important things: after/wants ensure the proxies are running before the agent starts (so the sockets exist), and bot.hardeningConfig is merged in last so the same set of restrictions applies uniformly.
What This Buys You In Practice
Concrete things that have actually happened (or that I've actively tested):
- Prompt injection attempt asking the bot to
catthe SA key: bot runscat /var/lib/google-proxy/sa-key.jsonas the basecamp user, getsPermission denied, returns that to the chat. Boring outcome. Good outcome. - Prompt injection asking the bot to dump environment variables: nothing useful in there.
HOME,PATH,XDG_*. NoGOOGLE_SA_KEY, noBRAVE_API_KEY— those exist only in the proxy services' environments. - Prompt injection asking the bot to
curldata to a webhook:curlisn't in the exec allowlist. The agent can ask its allowlistedbrowser-fetchCLI to hit a URL, butbrowser-fetch's server-side validation rejects non-HTTP schemes, blocks RFC 1918, and only returns page content as markdown — no way to POST data out. - OpenClaw CVE drops (real ones, like the Claw Chain series): I update the
openclaw-nixinput in my flake, runnh os switch, and the gateway restarts on the new version. My credentials don't move. They never lived in OpenClaw.
The Pattern, Generalized
Every proxy in my repo follows the same shape:
graph LR
subgraph File ["bot-service.nix"]
direction TB
S[serverPkg]
C[clientPkg]
end
subgraph Config ["bot.nix"]
direction TB
U[proxy-user]
A[agenix secret]
SVC[systemd service]
EXEC[exec allowlist]
end
S --> SVC
A --> SVC
U --> SVC
C --> EXEC
Browser, Google APIs, Anna's Archive, Sonarr/Radarr, Whisper transcription — all of them are the same shape with different innards. Once you've built one, the next one is a copy-paste-edit job.
Do This Today ⚡
If you run OpenClaw on NixOS and any of your credentials are in the agent's environment or workspace:
- Grep your modules for the credential.
grep -r BRAVE_API_KEY nixos/,grep -r GOOGLE_SA_KEY_PATH nixos/. Anywhere it appears in the agent'ssystemd.services.<bot>.environmentis a place it shouldn't be. - Move it to a proxy. Steal the structure of bot-browser-fetch.nix as a starting template. Read JSON in, validate, call the API, return JSON out.
- Wire a new user, a new agenix secret with the proxy's owner, and a new systemd service. Add the client CLI to
baseExecCommandsinbot-common.nix. - Rebuild.
nh os switch. Verify the agent can still do its job and the credential is no longer reachable from its shell.
Takes about an afternoon per credential the first time, an hour each after that.
The whole repo is at gitlab.com/slenderq/justin-nix — the bot files are under nixos/bot-*.nix and nixos/basecamp.nix / nixos/erdos.nix / etc. Copy whatever you find useful.
The agent stays smart. The keys move. Declarative boundaries hold across reboots, rebuilds, and CVEs.
Header image by Igor Saikin on Unsplash
Content on this blog was created using human and AI-assisted workflows described here. Original ideas and editorial decisions by Justin Quaintance.