Skip to content
← articles
updated Hermes AgentDockerSelf-Hosted AIVPSDocker Swarm

Running Hermes Agent in Docker on a VPS, Headless

What the Hermes Agent Docker image and Docker Compose file actually ship, how to persist HERMES_HOME and run isolated profiles, the dashboard's auth model, and the Swarm traps that cost real time running it on a VPS.

You know Docker. This is not an install guide. It is what the Hermes Agent image and Compose file actually contain, what breaks when you run it on a VPS instead of a laptop, and the handful of traps that are not documented anywhere until you hit them.

What the Hermes Agent Docker image actually ships

The published Dockerfile builds from debian:13.4, pinned by its sha256 digest rather than a tag, because the rest of the build (a pinned uv, a pinned Chromium) already comes from a sha-verified lock and a floating base image would drift under them. The first build stage compiles SQLite 3.53.4 from source and ships that .so instead of Debian 13’s bundled 3.46.1, which still carries the upstream WAL-reset corruption bug. If you have ever seen a Hermes installation complain about a corrupt state.db on a distro package of SQLite, this is why the container image sidesteps it entirely.

PID 1 inside the container is s6-overlay, not tini. Tini used to reap the zombie processes that pile up when Hermes spawns MCP stdio subprocesses, git, or bun as PID 1; s6-overlay does the same reaping non-blockingly and additionally supervises the main Hermes process, the dashboard, and per-profile gateways as named services under /etc/s6-overlay/s6-rc.d/. A thin compatibility shim at /usr/bin/tini still exists for orchestration templates that hardcode the old entrypoint, but it just strips tini’s CLI flags and re-execs /init.

The container runs as a non-root hermes user, UID 10000 by default, overridable at boot with HERMES_UID and HERMES_GID so files it writes stay owned by whatever host user you actually are. HERMES_HOME is baked to /opt/data, and that is the one path declared VOLUME. Everything else under /opt/hermes, the venv, the frontend bundle, the install itself, is copied in read-only (--chmod=a+rX,go-w) and owned by root. That split matters later: the code is immutable, the data volume is the only thing you need to keep.

One default is easy to assume wrong: putting Hermes in a container does not, by itself, sandbox what it runs. The tool-execution backend defaults to local, meaning a shell command the agent runs executes inside this same container, with the same filesystem and the same network namespace as Hermes itself. The image installs the docker-cli package as a system dependency for exactly the case where you want more isolation than that: point a task at the docker backend instead, and Hermes shells out to that CLI to launch a sandboxed sibling container (cap-drop all, no new privileges, resource limits) to run the command in. The usual way to wire that up from inside a container is mounting the host’s Docker socket into the Hermes container. Local is the default. Disposable is a choice you make per task.

The Hermes Agent Docker Compose file, read straight

The shipped docker-compose.yml runs two services, not one:

services:
  gateway:
    build: .
    image: hermes-agent
    container_name: hermes
    restart: unless-stopped
    network_mode: host
    volumes:
      - ~/.hermes:/opt/data
    environment:
      - HERMES_UID=${HERMES_UID:-10000}
      - HERMES_GID=${HERMES_GID:-10000}
    command: ["gateway", "run"]

  dashboard:
    image: hermes-agent
    container_name: hermes-dashboard
    restart: unless-stopped
    network_mode: host
    depends_on:
      - gateway
    volumes:
      - ~/.hermes:/opt/data
    environment:
      - HERMES_UID=${HERMES_UID:-10000}
      - HERMES_GID=${HERMES_GID:-10000}
    command: ["dashboard", "--host", "127.0.0.1", "--no-open"]

That is the shape, trimmed of the commented-out blocks for Microsoft Teams and Google Chat credentials, both of which are real keys in the file (TEAMS_CLIENT_ID, GOOGLE_CHAT_SERVICE_ACCOUNT_JSON, and the rest), just opt-in. Both services mount the same host path to /opt/data, both run as the same remapped UID, and the gateway and the dashboard are intentionally separate containers sharing one data volume rather than one process doing both jobs.

The file’s own top comment tells you how to run it, passing your host user’s IDs in so whatever you own stays owned by you inside the container too:

Bring the stack up

  1. Gateway + dashboard

    HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d

Its security notes are worth reading verbatim, because they are the two mistakes people actually make: the dashboard binds to 127.0.0.1 by design, since it stores API keys, and the comment is explicit that remote access should go through an SSH tunnel or a reverse proxy that adds authentication, never by passing --insecure --host 0.0.0.0. The second note is about the OpenAI-compatible API server, which stays off unless you uncomment API_SERVER_HOST and API_SERVER_KEY together, because the key is mandatory the moment the server is reachable beyond localhost.

Persisting HERMES_HOME, and profiles for isolated instances

Everything that makes a Hermes install yours lives under HERMES_HOME: config.yaml, SOUL.md, MEMORY.md and USER.md, the skills/ folder, and state.db. Mount that one directory and a container rebuild loses nothing. Lose that mount and you have a fresh install with no memory of ever having run.

If you want more than one agent on the same box, Hermes calls this a profile, and it is a first-class concept, not a workaround. Inside the running gateway container:

Create an isolated profile

  1. Create it

    hermes profile create coder
  2. Use it

    coder setup

That creates ~/.hermes/profiles/coder/ with its own config.yaml, .env, SOUL.md and state.db, and it wires up a coder command too, a shortcut for hermes -p coder. A personal assistant profile and a coding-agent profile on the same host never share memory, never share skills, and never write into each other’s system prompt by accident, which is the thing that actually goes wrong when two agents share one home.

The Docker image plans for exactly this. The Dockerfile declares static s6 services for the main Hermes process and the dashboard at build time, but per-profile gateway services get registered dynamically at runtime under /run/service/, which is tmpfs and gets wiped on every container restart. A cont-init.d script named 02-reconcile-profiles runs before anything else starts and rebuilds those service slots by reading $HERMES_HOME/profiles/<name>/ off the mounted volume. In practice: one container, one volume, as many isolated Hermes instances as you have profiles for, surviving a restart without you touching anything.

The Hermes Agent web UI and dashboard, behind auth

The dashboard binds to loopback by default, as the Compose file’s own comment says. Opening it to anything beyond your own machine means a tunnel, a reverse proxy, or config.yaml’s dashboard.basic_auth.username and .password keys, which the CLI will seed for you and which the dashboard auth plugin layer checks are actually enabled before it trusts them.

Here is the trap, and it is not Hermes-specific. A browser’s native WebSocket API has no way to set an Authorization header on the handshake. If a reverse proxy in front of the dashboard gates every path, including the WebSocket ones, with basic auth, the browser’s normal HTTP requests pass (the browser resends the credential), but the WebSocket upgrade never carries it, and the UI hangs on “Connecting” forever. I hit this running Hermes behind Traefik: the fix was a second Traefik router scoped only to the WebSocket path, authenticated with a ?token= query parameter instead of basic auth, bypassing the first router entirely. The same trap hit OpenClaw’s web UI when I evaluated it the same week, which is how I know it is a pattern at the edge, not a bug in either project.

Hermes’ own source has since formalized the same split one layer down, inside the dashboard process itself. In gated mode, a WebSocket upgrade never accepts the dashboard’s plain session token, a comment in the code is explicit that a leaked constant must not grant access by itself. Instead the browser mints a single-use ticket with a 30-second TTL and presents it as a ?ticket= query parameter or a hermes-gateway-ticket.<ticket> subprotocol, a server-spawned child process gets a longer-lived ?internal= credential instead, and a signed-in session can fall back to a provider-verified ?token=, the same verification path the REST API’s bearer auth uses. None of those three is a header, because a header is exactly what a WebSocket handshake cannot reliably carry. The code’s own history note says it plainly: before that ticket path existed, gated mode’s WebSocket upgrade had no credential it would accept at all, and it simply failed closed.

The lesson generalizes past Hermes. Any time you put session-style auth in front of a long-lived connection, check whether that connection is a WebSocket before you assume the auth layer covers it. If you are proxying the dashboard yourself, give the WebSocket path its own token route rather than trusting basic auth to follow the upgrade.

My swarm: bento, Traefik, Portainer

My own Hermes runs on a VPS I turned into a Docker Swarm with bento, my own installer, which sets up Traefik and Portainer in front of whatever you deploy. Hermes runs headless in that swarm, Telegram gateway on, no browser tab ever opened on the box itself. The Compose file above is the single-host shape; Swarm changes a few things once you deploy it as a stack, and Portainer becomes the thing you redeploy through instead of docker compose up directly.

The traps that actually cost time

None of these show up in a demo. All of them showed up on a real VPS.

Symptom, real cause, fix

What you seeWhat is actually happeningWhat fixed it
A container calling its own public hostname times outuserland-proxy: false drops the hairpin NAT path on a single-IP VPS; the packet leaves the public interface and never routes backRemove the key. Docker's default of true is the correct setting here
A same-host service takes about 130 seconds to answer, not under oneCalling a container by its public FQDN routes through loopback TLS instead of the internal networkCall it by its internal service name, not its public hostname
A container just disappears, no error in its own logsThe kernel OOM-killed it. The orchestrator only ever sees process_lost with a null exit codedmesg | grep oom is the real log. Nothing Docker prints names it
A provider call fails with a double-credential errorThe SDK sets one auth header from an env var and an explicit header sets a second onePick one source for the credential, never both at once
A service sits at 0/1 replicas and the proxy returns 404docker restart on a Swarm task orphans it instead of rescheduling itdocker service update --force <service>. Never docker restart a task directly
agent.log throws Permission deniedA diagnostic run as root inside the container left root-owned files in a directory the service user cannot touchchown the affected files back to the service UID (10000 by default)

The two fixes worth keeping on hand, the same two every single time:

The two commands you actually run

  1. Redeploy a Swarm service, never restart a task

    docker service update --force <service>
  2. Fix root-owned files under HERMES_HOME

    chown -R 10000:10000 /opt/data

One more pattern is not a bug, just worth naming. When another container on the same box needs the Hermes binary, the answer is not a second image. Grafting the running install’s own volumes into the other service’s container gives it the binary and the data without a rebuild. That is how my Paperclip containers call Hermes: the volumes are grafted in by bento’s installer after both stacks are already deployed, since Compose alone has no clean way to express “mount this other stack’s volume” when deploy order between the two stacks isn’t fixed.

Best VPS for Hermes Agent: requirements, not brands

There is no provider I would name here, and a “best VPS” list is mostly affiliate copy wearing a review’s clothes. What actually matters, in order:

What a Hermes Agent host actually needs

  • Required:
    A single public IP you control Docker's NAT behavior onSingle-IP VPS plus userland-proxy: false is the exact combination that breaks hairpin NAT. Know which one you're renting before you touch that setting.
  • Required:
    Memory headroom independent of the agent's own workloadThe dashboard has an open memory leak tied to live sessions, not idle time, and it has reached multiple gigabytes within an hour on a real install. Size for that process separately from the gateway.
  • Required:
    Swarm or Compose support, and a non-root service userThe image already drops to UID 10000 for you. A host that forces everything through root undoes that protection the first time someone runs a one-off command as root inside the container.
  • Required:
    SSH key access, not a browser-only consoleEverything here, the Compose file, the Swarm commands, the chown fixes, assumes a real shell. A provider console that only gives you a web terminal makes every one of these traps slower to fix.
  • Required:
    Persistent block storage you actually back upHERMES_HOME is the only volume that matters. If the host's storage disappears with the instance, so does every profile on it.
Hermes Agent hosting is a short checklist, not a provider comparison.

Pin a version, then read the release notes

The dashboard carries its own open one. Issue #80527 reports its process growing without bound while serving live sessions, from a stable baseline around 250 MB to 6.3 GB in one occurrence and 7.6 GB, with swap fully exhausted, in another, both ending in an OOM kill that took down every client connected through it. The reporter traced the likely cause into tui_gateway/server.py: the raw session transcript is retained in memory for resume and durability even after context compression replaces what the agent itself sees, and a long session with heavy tool output grows that retained copy without a size bound. The same dashboard with no active session sits flat around 290 MB, so the leak only shows up once you actually use the UI for a while.

Until that lands a fix, the operating rule is boring on purpose: pin the image tag you tested, read the notes before you bump it, and on a memory-constrained VPS, do not leave a long dashboard session open unattended for hours. Restart the dashboard service on a schedule if you must leave it running. The gateway container, which is where your gateways and cron jobs actually live, is not the one with this bug.

Hermes Agent Docker, quick answers

Can I run Hermes Agent in Docker?

Yes. Nous Research ships a Dockerfile and a docker-compose.yml with the source: an s6-overlay-supervised image running as a non-root user, with two services, a gateway and a dashboard, sharing one HERMES_HOME volume.

How do I persist Hermes Agent's data in Docker?

Mount a host path to /opt/data, which is where HERMES_HOME lives inside the container (the shipped compose file uses ~/.hermes:/opt/data). That single volume holds config.yaml, SOUL.md, memory, skills and state.db. Everything else in the image is replaceable.

Why does my Hermes Agent dashboard hang on Connecting?

Most often a reverse proxy gating the dashboard with basic auth in front of its WebSocket path. Browsers cannot send an Authorization header on a WebSocket upgrade, so the normal HTTP routes authenticate fine and the WebSocket route hangs forever. Give the WebSocket path its own token-based route instead of basic auth.

What is the best VPS for Hermes Agent?

There is no brand answer. What matters is a single public IP you understand Docker's NAT settings on, memory headroom sized for the dashboard's own process separately from the agent's workload, Swarm or Compose support with a non-root service user, real SSH access, and persistent storage you back up.

Can I run multiple Hermes Agent instances on one VPS?

Yes, through profiles. hermes profile create <name> gives each instance its own home directory, config, memory and state database under HERMES_HOME/profiles/, and the Docker image reconciles per-profile gateway services from that directory on every container restart.