Provision a hardened Hermes Agent on Hetzner with rootless Podman and Tailscale.
- Terraform >= 1.5
- Ansible >= 2.15
- Hetzner Cloud API token
- Tailscale pre-auth key (reusable or ephemeral)
# 1. Copy and edit Terraform variables
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
vim terraform/terraform.tfvars
# 2. Copy and override Ansible defaults
vim ansible/inventory/group_vars/all.yml
# Required: set hermes_image_ref to a pinned digest
# Resolve the latest digest:
# curl -s "https://hub.docker.com/v2/repositories/nousresearch/hermes-agent/tags/main" | jq -r '.images[] | select(.architecture == "amd64" and .os == "linux") | .digest'
# Then set: hermes_image_ref: 'docker.io/nousresearch/hermes-agent@sha256:<digest>'
# 3. Deploy
HCLOUD_TOKEN=your_token TAILSCALE_AUTH_KEY=tskey-auth-... ./deploy.shdeploy.sh runs Terraform (provisions VPS) then Ansible (configures it). Ansible connects via the server's public IPv4 — Tailscale isn't available until the Tailscale role runs. Running terraform plan shows the diff between Terraform state and real infrastructure; this is normal behavior, not an error. apply reconciles them.
Use this procedure for a first disposable test deployment. The goal is to validate Terraform, Ansible, Tailscale access, rootless Podman, and the Hermes runtime wiring before using a pinned production image.
Important: Run this only against a disposable Hetzner VPS. The smoke test may use
ALLOW_UNPINNED_IMAGE=truefor convenience. Do not use this override for production.
Create and edit the Terraform variables file:
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
vim terraform/terraform.tfvars| Component | Detail |
|---|---|
| VPS | Hetzner cx23, Ubuntu 24.04 |
| Container Runtime | Rootless Podman (Quadlet default, Compose fallback) |
| Network | Tailscale SSH + subnet access |
| Service | Hermes Agent (gateway, API, optional dashboard) |
| Mnemosyne Memory | SQLite-vec memory backend (optional, toggle via hermes_mnemosyne_enabled) |
| Backups | Daily local backups to /home/hermes/backups/; optionally encrypted with age |
- Rootless container, all capabilities dropped, no-new-privileges
- All ports bound to 127.0.0.1 (access via Tailscale SSH tunnel)
- UFW default deny, only tailscale0 allowed
- Read-only root filesystem, tmpfs for /tmp and /run
- API key auto-generated, .env at 0600
- Image digest pinning required (fail-closed if missing)
See SECURITY.md for the full security model, threat model, and design rationale.
# Access dashboard via SSH tunnel
ssh -L 9119:127.0.0.1:9119 hermes@<tailscale-ip>
# Open http://127.0.0.1:9119 in browserMnemosyne provides persistent memory (SQLite-vec) for the Hermes Agent, enabling long-term recall across conversations.
# ansible/inventory/group_vars/all.yml
hermes_mnemosyne_enabled: trueWhen enabled, two dedicated Ansible roles handle the integration:
mnemosyne_build— builds a custom container image extending the pinned Hermes base withmnemosyne-memory[all], tags it aslocalhost/hermes-mnemosyne:latestmnemosyne_runtime— runs after the container starts: waits for the health endpoint, runsmnemosyne.installinside the container (plugin symlink + config.yaml update), and restarts the service only if changes were made
The Quadlet/Compose template uses the custom image and sets MNEMOSYNE_DATA_DIR=/opt/data/mnemosyne for SQLite persistence.
The runtime install is automated by Ansible. The only manual step is selecting mnemosyne as the active memory provider:
ssh hermes@<tailscale-ip>
podman exec -it hermes /opt/hermes/.venv/bin/hermes memory setup
# Select 'mnemosyne' from the provider listVerify with /opt/hermes/.venv/bin/hermes memory status (inside container) — should show Provider: mnemosyne.
ssh root@<tailscale-ip>
sudo -u hermes XDG_RUNTIME_DIR=/run/user/$(id -u hermes) podman exec hermes python3 -m mnemosyne.install
sudo -u hermes XDG_RUNTIME_DIR=/run/user/$(id -u hermes) systemctl --user restart hermes.service
ssh hermes@<tailscale-ip>
podman exec -it hermes /opt/hermes/.venv/bin/hermes memory setupMemory data lives at /home/hermes/.hermes/mnemosyne/ and is included in daily backups.
Daily backups run via cron at 2am (user hermes). They archive /home/hermes/.hermes/ (data + auto-generated .env) to /home/hermes/backups/ with 30-day retention. When Mnemosyne is enabled, memory data at /home/hermes/.hermes/mnemosyne/ is included automatically.
# Backup file format (plain):
/home/hermes/backups/hermes-backup-20260521-020000.tar.gz
# Backup file format (encrypted):
/home/hermes/backups/hermes-backup-20260521-020000.tar.gz.ageEnable encryption by setting backup_encryption_enabled: true and backup_age_recipient (your age public key) in group_vars/all.yml.
Restore from any backup archive to a running server:
# Plain backup:
./scripts/restore.sh /path/to/hermes-backup-20260521-020000.tar.gz
# Encrypted backup (requires age private key):
./scripts/restore.sh /path/to/hermes-backup-20260521-020000.tar.gz.age --age-key ~/.age/key.txtThe script auto-detects the Tailscale IP (falls back to --tailscale-ip if Terraform state is missing), copies the archive, stops the runtime, extracts, fixes permissions, restarts, and runs verify.yml.
terraform/ # Hetzner VPS provisioning
ansible/ # Server configuration (5 roles)
inventory/
group_vars/ # Ansible group variables (all.yml)
deploy.sh # One-command deploy (auto-generates hosts.yml)
teardown.sh # Destroy everything
scripts/repo_check.sh runs local security and consistency checks against the repo. It scans for:
- Secret leakage (API keys, tokens in committed files)
- Dangerous container flags (
--privileged, host networking, etc.) - Image pinning and port binding enforcement
- Shell / YAML / Ansible syntax errors
- Optional Terraform validation
./scripts/repo_check.shOutput is written to hermzner-local-check-report.txt (gitignored).
See ansible/inventory/group_vars/all.yml for all configurable options, including feature toggles (hermes_dashboard_enabled, hermes_mnemosyne_enabled, hermes_start_runtime), resource limits, backup settings, and security policies.
