ZeroClaw on NixOS

Platform architecture for a self-hosted AI agent: a NixOS VM on Proxmox, deployed with Colmena, isolated in an OPNsense DMZ, with pinned container images kept current by Renovate.

Platform

The VM, named agents, runs NixOS and is defined as one host in a Nix flake. Containers, systemd units, networks, secret templates, and the markdown files defining the agent’s persona are all Nix modules in one repository. Deployment is a single Colmena command:

nix run '.#' -- apply --on @remote

A fully declarative host matters more for an agent VM than for most machines, because agent configuration changes frequently. Every change is a commit followed by a deploy. A bad change rolls back to the previous NixOS generation, and a lost VM is rebuilt with one deploy. The only state that requires backup is the agent’s memory and the Signal account data.

Network isolation

The VM does not sit on the LAN. OPNsense places it in a DMZ with a dedicated interface and firewall rules: it can reach the internet and accepts inbound traffic on a small set of ports, but it cannot initiate connections to the internal network.

The threat model assumes the agent can be wrong or compromised. It executes shell commands, browses the web, and reads email, all of which are standard prompt-injection surfaces. Segmentation bounds the blast radius at one network segment rather than the whole LAN.

Services

Podman containers inside the VM share one network:

  • zeroclaw, the agent runtime, which runs two agents (TARS and CASE)
  • signal-cli, which holds the Signal account and exposes a JSON-RPC daemon
  • traefik, for TLS termination, with a wildcard certificate obtained via ACME DNS-01 against Cloudflare
  • alpaca-mcp, an MCP server for stock market data (paper trading)
  • fastmail-mcp, an MCP connector for Fastmail

ZeroClaw reaches each service through config.toml. Signal is a channel pointed at the signal-cli daemon:

[channels.signal.default]
account  = "+1XXXXXXXXXX"
enabled  = true
http_url = "http://signal-cli:8080"

The MCP servers are entries in the same file, reached over the container network. Signal state is owned entirely by signal-cli, so the agent runtime can restart, update, or crash without touching the Signal account. In practice this separation has limited the cost of bad configuration pushes to the agent container alone.

Startup ordering is the one dependency the configuration file cannot express. The zeroclaw systemd unit requires the signal-cli unit and blocks on podman wait --condition=healthy signal-cli, where the health check issues a real JSON-RPC call rather than a port probe. The agent therefore never starts against a daemon that is still initializing.

Secrets

Credentials live in a sops-encrypted file that sops-nix decrypts at activation time. A sops.templates entry renders the API keys into config.toml at runtime, so secrets follow the same declarative path as the rest of the configuration without entering the repository or the Nix store in plaintext.

Updates

Every container image is pinned to an exact tag in its Nix module. Renovate watches the repository and opens a pull request when Traefik or an MCP server publishes a new version; the merge rides the next deploy like any other commit.

The zeroclaw container updates on a digest check instead. A daily systemd timer pulls the pinned tag, compares image digests, and restarts the service only when the image has actually changed:

old=$(podman inspect zeroclaw --format '{{.ImageDigest}}')
podman pull "$ZEROCLAW_IMAGE"
new=$(podman image inspect "$ZEROCLAW_IMAGE" --format '{{.Digest}}')
if [ "$old" != "$new" ]; then
  systemctl restart podman-zeroclaw.service
fi

Nothing restarts without cause, and the component that does restart is the stateless one.

Summary

The design rests on three decisions. The agent is isolated in its own network segment before it is given any capabilities. The channel daemon is kept separate from the agent runtime, so account state survives runtime failures. Every image is pinned, and Renovate turns each update into a reviewable commit. Declaring the whole machine is what keeps these properties true after every deploy, not just on the day they were set up.

Light