Hermes · the agent, in a box
Hermes is the agent driving most of the stack: the gateway (API, dashboard, agent) in one Docker container, with every secret in a .env that stays on the box.
Hermes is the thing running most of this stack. It's the agent I talk to in the terminal, the one that writes this journal, and the one that drives the other containers. Everything else on the box is either a model it calls or a tool it reaches for, so the first of the per-box write-ups starts here.
It runs as a single Docker container from the upstream nousresearch/hermes-agent image, and the entire entrypoint is one command: gateway run.
Config
The compose file, slimmed to what matters. Every secret is a ${REF} pulled from a .env that never leaves the box, so this file is safe to publish as-is.
services:
hermes:
image: nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
command: gateway run
environment:
# Secrets loaded from .env file
- HERMES_DASHBOARD=1
- API_SERVER_ENABLED=true
- API_SERVER_HOST=0.0.0.0
- API_SERVER_KEY=${HERMES_API_KEY}
- HERMES_DASHBOARD_HOST=0.0.0.0
# Web tools
- SEARXNG_URL=${SEARXNG_URL}
- PINCHTAB_URL=${PINCHTAB_URL}
- PINCHTAB_TOKEN=${PINCHTAB_TOKEN}
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME=${HERMES_ADMIN_USER}
- HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=${HERMES_ADMIN_PASSWORD}
- HERMES_DASHBOARD_BASIC_AUTH_SECRET=${HERMES_SESSION_SECRET}
volumes:
- ./data:/opt/data
shm_size: '1g'
What each line is doing
| Line | Why it's there |
|---|---|
command: gateway run |
Runs the gateway: the HTTP API server, the web dashboard, and the agent runtime in one process. Inside the image, s6 supervises it: if the gateway crashes, it's back within seconds without the container dying. |
API_SERVER_HOST=0.0.0.0 |
Binds the API to every interface instead of loopback. Deliberate: the box sits behind a firewall and I reach it over a VPN, so loopback-only buys nothing. The API itself is gated by API_SERVER_KEY. |
HERMES_DASHBOARD=1 · HERMES_DASHBOARD_HOST=0.0.0.0 |
Turns on the web admin panel and binds it wide, same reason. |
HERMES_DASHBOARD_BASIC_AUTH_* |
The panel sits behind HTTP basic auth, with a session secret that signs the auth cookie. |
SEARXNG_URL · PINCHTAB_URL · PINCHTAB_TOKEN |
Points Hermes at the web-search and web-fetch containers from the wiring diagram. |
./data:/opt/data |
All state: config, memory, sessions, skills. One host directory, so rebuilding the container loses nothing. |
shm_size: 1g |
Docker's default /dev/shm is 64 MB and the headless browser eats it. A gigabyte keeps Chromium from crashing. |
restart: unless-stopped |
Comes back after a reboot without me doing anything. |
The .env contract
Seven values, all of them on the box:
| Var | What it is |
|---|---|
HERMES_API_KEY |
Bearer key for the HTTP API, minimum 8 characters. |
SEARXNG_URL |
Where the local SearXNG instance listens. |
PINCHTAB_URL |
Where PinchTab listens. |
PINCHTAB_TOKEN |
PinchTab's access token. |
HERMES_ADMIN_USER · HERMES_ADMIN_PASSWORD |
Basic-auth credentials for the dashboard. |
HERMES_SESSION_SECRET |
Signs the dashboard session cookie. |
A minimal file to start from, every value a placeholder:
# sample .env: replace every value before running
HERMES_API_KEY=change-me
SEARXNG_URL=http://searxng:8080
PINCHTAB_URL=http://pinchtab:8080
PINCHTAB_TOKEN=change-me
HERMES_ADMIN_USER=admin
HERMES_ADMIN_PASSWORD=change-me
HERMES_SESSION_SECRET=change-me
The two URLs use the sibling containers' network names; the ports are whatever those services expose. For the three secrets, anything long and random works, e.g. the output of openssl rand -hex 32.
That split is the whole point: the compose describes the wiring and can be public; the .env holds the secrets and never is.
From zero to running
Put the sample from above into a .env next to the compose file, fill in the seven values, then:
docker compose up -d
docker compose logs hermes # watch the gateway come up
Two checks that it's actually alive: the dashboard answers with a login challenge instead of a page, and the API rejects a bad key and accepts the right one. If both pass, the box is doing its job.
If the container bails on permissions instead, that's the image's doing: it runs as an unprivileged user (UID 10000), so the mounted data directory needs to be owned by a user the container can write to, or you point HERMES_UID/HERMES_GID at the right one. The upstream alternative to filling the .env by hand is a one-off setup wizard, docker run -it --rm -v ./data:/opt/data nousresearch/hermes-agent setup, which walks you through the keys and writes the file itself.
Where it plugs in
From the diagram on the front page: Hermes talks to LiteLLM over the OpenAI API, the same path every other tool uses, and it reaches the MCP servers directly (SearXNG for search, PinchTab for fetch). Those are the two URLs above. The model containers it ends up at live on the two B70 cards, one model per card. The gateway itself listens on 8642 by default for its OpenAI-compatible API and a health endpoint, and this compose publishes no ports at all, so nothing outside the shared Docker network can reach the box.
A note on latest
The image tag is latest, which sounds sloppy and isn't quite: that tag only moves on a full stable promotion, not on every dev push (those land on a separate main image), and versioned tags plus digests exist when I want an exact pin. Still, docker compose pull is an update you can't preview, so I update on purpose: pull, restart, ask the agent a question that touches the tools and the terminal, and only then move on. Backup is the same shape: everything the agent knows (config, memory, skills, sessions) lives in the one mounted ./data directory, so an update can't eat the state, and protecting the box is protecting that one directory. Same reason for unless-stopped rather than always: a manual stop survives a reboot, so maintenance doesn't fight the launcher.
Why the 0.0.0.0 binds are OK
The compose binds two servers to every interface. What keeps that acceptable is everything around it: no ports published by the container itself, a key-gated API, a basic-authed dashboard, and a firewall in front. If I ever put any of that on the open internet, the binds are the first lines I'd take out. One more guard I don't configure because it's on by default: unattended gateway sessions get tool-loop hard stops, so an agent stuck repeating the same call halts instead of burning the box's time.