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.

setuphermesdockeragent

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.

RELATED
2026-10-23