Upstream ships /opt/hermes (app + .venv + scripts) read-only root, which blocks the agent self-modifying and the gateway auto-installing the WhatsApp bridge's node_modules in place. Add a derived Containerfile layer (FROM the upstream build) that chowns/chmods /opt/hermes writable by the runtime hermes user. Done in the image, not a volume: a volume over /opt/hermes copies-up once then freezes the app, silently defeating AutoUpdate=registry. Persistence stays on the /opt/data volume. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011D3YeWKpjg5bT488fVanCH
hermes
NousResearch Hermes Agent — a
self-improving AI agent — packaged for lair infra and published to
git.lair.cafe/lair/hermes.
How it's built
Upstream ships its own Dockerfile (debian 13 + s6-overlay), so we build in two
stages — build.sh and the images workflow both do this:
- Upstream — build straight from the upstream git context at the release tag
into a local tag:
podman build github.com/NousResearch/hermes-agent.git#<tag>. - Derived —
ContainerfiledoesFROMthat and makes the/opt/hermesapp tree writable by the runtimehermesuser (uid 10000), then publishesgit.lair.cafe/lair/hermes:{<version>,latest}.
The writable-tree layer gives the agent "untied hands" (self-modify; the gateway
can auto-install the WhatsApp bridge's node_modules in place) without the
volume trap: a volume over /opt/hermes would copy-up once and freeze the app,
silently defeating AutoUpdate=registry. Baked into the image, it ships
hands-free on every pull and refreshes cleanly on update. Persistence belongs
on /opt/data (skills, memory, sessions, the WhatsApp bridge via bridge_script),
never in /opt/hermes.
Builds are release-triggered (daily poll of the GitHub releases API; a build
runs only when that version isn't already in our registry) and self-healing
(a failed build leaves the version absent, so the next poll retries). When the
build definition changes (e.g. this writable layer) for an already-published
version, republish with the workflow's force dispatch input. Locally:
HERMES_REF=v0.17.0 ./build.sh
How it runs (single container, gateway + dashboard)
The image is an s6-overlay supervisor. The command selects the mode — the
default (no command) is the interactive CLI, which exits without a TTY under
systemd and crash-loops. The supported headless setup (per the image's own
startup guidance) is one container running gateway run with the dashboard
supervised alongside via HERMES_DASHBOARD:
Exec=gateway run→ the agent daemon (hermes gateway run --replace), s6-supervised.HERMES_DASHBOARD=1→ the dashboard web UI (binds0.0.0.0:9119) in the same container — which is what lets it reach the gateway (two bridge-networked containers could not, unlike upstream's host-networked compose).- The image fails closed on a non-loopback dashboard bind: it refuses
0.0.0.0unless OAuth is configured orHERMES_DASHBOARD_INSECURE=1is set. We expose on the trusted LAN without auth, so we opt in. ⚠ Anyone on the LAN can reach the API-key-storing UI — switch toHERMES_DASHBOARD_OAUTH_CLIENT_IDfor real auth if that's not acceptable.
Persistent state — config.yaml, .env, sessions, memory, skills — all lives
under /opt/data (the single :Z volume). Keys/backend go in those mounted
files, never in the image or quadlet.
Deploying on bob
See hermes.container — a rootful quadlet matching the
existing agent-zero / open-webui services. As deployed:
-
Publish
git.lair.cafe/lair/hermes:latest(theimagesworkflow). -
sudo install -d -o 10000 -g 10000 /var/lib/hermes -
Drop
config.yamlinto/var/lib/hermes(owned10000:10000) — LLM backend → local sovereign inference via Hermes'customOpenAI-compatible provider:# /var/lib/hermes/config.yaml model: provider: "custom" base_url: "http://hanzalova.internal:31313/v1" # same endpoint open-webui uses api_key: "<your-inference-key>" default: "<model-id>" # see: curl …/v1/models # context_length / max_tokens optionalOther secrets (web-search/tool keys, messaging tokens) go in
/var/lib/hermes/.env. -
Install
hermes.containerto/etc/containers/systemd/,daemon-reload,start hermes.service. Dashboard then serves on the LAN athttp://bob.hanzalova.internal:5100/;AutoUpdate=registryenrolls it in the host'spodman-auto-update.timer.