hermes: boot-persistent quadlet units for the gateway, dashboard, and webui

The trio ran as bare compose containers and died silently at the Aug 26
reboot — nothing restarts compose containers at boot. Each now has a
boot-persistent unit (WantedBy=default.target; the v1 quadlet generator on
this host cannot enable generated units). The deploy handler retires the
compose-era containers only while the units are not yet active, then
(re)starts each unit.

Units run the image under a localhost/hermes-agent:latest alias tag (the
quadlet create path on this host cannot resolve registry-prefixed refs from
local storage); the deploy handler tags first. The gateway's :8090 publish
is dropped — nothing consumes it; the public vhost now routes to the
dashboard's login-gated UI on 9119.
This commit is contained in:
Frank
2026-08-30 07:22:20 -07:00
parent 56763e014e
commit 915d49d663
4 changed files with 152 additions and 0 deletions
@@ -0,0 +1,31 @@
# Quadlet unit: Hermes web dashboard (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/hermes-dashboard.container
# Unit name: hermes-dashboard.service (container name: hermes-dashboard)
#
# Management UI for the agent (config, API keys, sessions). Published on
# 9119 so Caddy's internal listener can reverse-proxy it; the public vhost
# hermes.franksdank.cloud terminates Authentik auth first, so this port is
# reachable from the LAN but gated twice (Authentik + its own login).
# Shares /data with the gateway: same agent state, two processes.
[Unit]
Description=Hermes web dashboard container
[Container]
ContainerName=hermes-dashboard
Image=localhost/hermes-agent:latest
Pull=never
Network=dankclaw_default
Environment=HERMES_DATA_DIR=/data
Environment=HERMES_HOME=/data
Volume=/home/dank/hermes-data:/data
PublishPort=9119:9119
Exec=bash -c 'hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build'
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,50 @@
# Quadlet unit: Hermex webui (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/hermes-webui.container
# Unit name: hermes-webui.service (container name: hermes-webui)
#
# Runs the open-source hermes-webui (github.com/nesquena/hermes-webui) in
# foreground mode: bootstrap.py creates .venv inside the mounted checkout,
# installs WebUI + agent deps, then execs server.py as a single foreground
# process. It reads HERMES_HOME=/data, so it drives the SAME agent state
# (memory, skills, sessions) as the gateway container. Loopback-only publish:
# the SSH tunnel (phone -> Dankclaw:22) is the sole ingress. The password
# gates Hermex logins and lives in the host .env (git-ignored).
[Unit]
Description=Hermes webui (Hermex) container
[Container]
ContainerName=hermes-webui
Image=localhost/hermes-agent:latest
Pull=never
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=HERMES_HOME=/data
Environment=HERMES_WEBUI_HOST=0.0.0.0
Environment=HERMES_WEBUI_PORT=8787
Environment=HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD}
# Headless host: never show the first-run wizard (a profile already
# exists in /data from the gateway).
Environment=HERMES_WEBUI_SKIP_ONBOARDING=1
Volume=/home/dank/hermes-data:/data
Volume=/home/dank/hermes-webui:/webui
PublishPort=127.0.0.1:8787:8787
Exec=python3 /webui/bootstrap.py --foreground
# JSON exec form on purpose: this quadlet generator joins a bare HealthCmd
# into ONE argv element with \x20 escapes and then shell-executes it — the
# python one-liner's quoting would be mangled. A JSON array is passed through
# verbatim. Start period covers first-boot pip installs into /webui/.venv.
HealthCmd=["python3","-c","import urllib.request;urllib.request.urlopen('http://127.0.0.1:8787/health')"]
HealthInterval=30s
HealthTimeout=10s
HealthRetries=5
HealthStartPeriod=600s
[Service]
Restart=always
RestartSec=5
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
[Install]
WantedBy=default.target
+43
View File
@@ -0,0 +1,43 @@
# Quadlet unit: Hermes agent gateway (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/hermes.container
# Unit name: hermes.service (container name: hermes)
#
# The messaging gateway (Signal adapter -> the host signal-cli daemon). The
# image's default s6 entrypoint would run the interactive TUI, which exits
# without a TTY; Exec= below replaces it with the headless gateway command,
# spliced by the v1 quadlet generator as trailing args after the image ref
# (same splice pattern proven live on authentik-worker). The gateway drops to
# the unprivileged hermes user (uid 10000) at runtime; /data is world-writable
# on purpose so that unmapped uid can write agent.log and its state there.
# SIGNAL_HTTP_URL uses the gateway alias, not the host LAN IP: from container
# space on this network only DNAT'd published ports accept connections to the
# LAN address; un-forwarded host ports refuse instantly (verified live
# 2026-08-30). The alias resolves to the network's own gateway IP.
[Unit]
Description=Hermes agent gateway container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=hermes
Image=localhost/hermes-agent:latest
Pull=never
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=HERMES_DATA_DIR=/data
Environment=HERMES_HOME=/data
Environment=SIGNAL_HTTP_URL=http://host.containers.internal:8080
Environment=SIGNAL_ACCOUNT=${SIGNAL_ACCOUNT}
Environment=SIGNAL_ALLOWED_USERS=${SIGNAL_ALLOWED_USERS}
Volume=/home/dank/hermes-data:/data
Exec=bash -c 'hermes gateway run --force -v'
[Service]
Restart=always
RestartSec=5
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
[Install]
WantedBy=default.target
+28
View File
@@ -566,6 +566,33 @@ deploy_authentik_outpost() {
echo "WARN: authentik-outpost did not come up active" >&2
}
deploy_hermes() {
# The hermes trio (gateway, dashboard, webui) shares one image and the
# /data volume and deploys as one unit: install the units, retire the
# compose-era containers holding the claimed names while the units are not
# yet active, then restart each unit. An explicit hermes deploy is a
# deliberate action, so restarting an already-running trio is intended.
# Boot persistence comes from [Install] in each unit file (generated units
# cannot be enabled by this host's v1 quadlet generator).
local unit
mkdir -p "$quadlet_unit_dir"
# The units run the image under a localhost/-aliased tag: this host's
# quadlet path can fail to resolve registry-prefixed refs from local
# storage at create time. Tag first, then (re)create via deploy.
podman tag docker.io/nousresearch/hermes-agent:latest localhost/hermes-agent:latest 2>/dev/null || true
for unit in hermes hermes-dashboard hermes-webui; do
cp "dankclaw/hermes/quadlet/$unit.container" "$quadlet_unit_dir/"
done
systemctl --user daemon-reload
if ! systemctl --user is-active --quiet hermes.service; then
podman stop hermes hermes-dashboard hermes-webui 2>/dev/null || true
podman rm hermes hermes-dashboard hermes-webui 2>/dev/null || true
fi
systemctl --user restart hermes.service
systemctl --user restart hermes-dashboard.service
systemctl --user restart hermes-webui.service
}
deploy_anubis() {
# Anubis is the bot-challenge proxy in front of the guarded vhosts.
# The image lives on ghcr.io; pull once, then the unit runs it from
@@ -660,6 +687,7 @@ deploy_service() {
frank-default-small) deploy_llm_small ;;
frank-default-npu) deploy_llm_npu ;;
frank-default-sightsound) deploy_llm_sightsound ;;
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
*) deploy_compose_service "$service" ;;
esac
}