Skip to content

Running Hermes Agent in Production Behind Traefik

I deployed Hermes Agent to my Hostinger VPS with the one-click Docker Compose template. The build log said Project deployed successfully. It lied — the container came up, but its HTTPS was broken and its admin dashboard was published to the public internet over plain HTTP.

This is the walkthrough of what the template actually gives you, the three defects I found on my own box, and the compose file I run now.


What Hermes Agent Is

Hermes Agent is a self-hosted AI assistant with tool-calling, built by Nous Research — think a persistent Claude Code that lives on your server instead of your laptop. The CLI surface is wide:

$ docker exec hermes-agent-xlzg-hermes-agent-1 hermes --help
usage: hermes [-h] [--version] [-z PROMPT] [-m MODEL] [--provider PROVIDER]
              {chat,gateway,proxy,cron,webhook,kanban,memory,mcp,dashboard,...}

gateway runs the messaging bridge (Telegram, Discord, Slack, WhatsApp, Signal). cron schedules recurring agent runs. dashboard serves the web UI. That last one is what you expose — and what you have to get right.

My running version:

$ hermes --version
Hermes Agent v0.16.0 (2026.6.5) · upstream f8adefde
Python: 3.13.5
OpenAI SDK: 2.24.0

The important detail: Hermes executes tools with real filesystem and shell access, and it holds your model API keys. Its dashboard is not a status page. Treat it like an SSH session with a web UI.

Nous publishes an official image at nousresearch/hermes-agent. My VPS was running Hostinger's repackaged ghcr.io/hostinger/hvps-hermes-agent instead, because that's what the one-click template installs. That distinction turns out to be the whole story — the defects below are in the wrapper, not in Hermes.

So the first fix is the simplest one: run the upstream image. Every compose file below uses nousresearch/hermes-agent, not the wrapper.


The Stack

                    Internet
              :80 ─────┴───── :443
              ┌────────▼────────┐
              │     Traefik     │  ← ACME TLS, docker provider
              │  root_default   │
              └────────┬────────┘
              ┌────────▼─────────┐
              │  Hermes  :4860   │
              │ hermes_default   │
              └──────────────────┘

Traefik terminates TLS, pulls routes from Docker labels, and gets Let's Encrypt certs via the TLS-ALPN challenge. Here's my actual /root/docker-compose.yml:

services:
  traefik:
    image: "traefik"
    restart: always
    command:
      - "--api=true"
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.file.directory=/dynamic"
      - "--providers.file.watch=true"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true"
      - "--certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}"
      - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - traefik_data:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /root/traefik-dynamic:/dynamic:ro

Three settings carry most of the weight:

  • exposedbydefault=false — containers are invisible to Traefik unless they carry traefik.enable=true. Opt-in, not opt-out. Keep it.
  • certificatesresolvers.mytlschallenge — the resolver name is arbitrary, and you pick it here. Remember it exactly; Defect 1 is what happens when a router asks for a different one.
  • entrypoints.web.http.redirections — every plain-HTTP request gets bounced to HTTPS before it reaches Hermes.

Defect 1: The Certificate Resolver Doesn't Exist

The template shipped this label:

- traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt

My Traefik defines exactly one resolver, and it isn't named letsencrypt:

--certificatesresolvers.mytlschallenge.acme.tlschallenge=true

Traefik doesn't fail the deploy over this. It logs and moves on:

$ docker logs root-traefik-1 2>&1 | grep -i "nonexistent"
ERR Router uses a nonexistent certificate resolver
    certificateResolver=letsencrypt routerName=hermes-agent-xlzg@docker

That error had been repeating for five weeks before I looked. Confirm by listing what ACME actually holds:

$ docker run --rm -v traefik_data:/le alpine cat /le/acme.json \
  | python3 -c "import sys,json; d=json.load(sys.stdin); \
    [print(k, [c['domain'] for c in v.get('Certificates') or []]) for k,v in d.items()]"

mytlschallenge [ ... certs for my other hosts, elided ... ]
                 # nothing matching hermes-agent-xlzg

Certs for every other host on the box. None for Hermes. Every HTTPS request to the Hermes hostname was being served Traefik's built-in self-signed cert — so anyone reaching it either clicked through a browser warning or got a hard TLS failure.

Fix: the resolver name in the label must string-match the one in the static config. There is no default and no fallback.

- traefik.http.routers.hermes.tls.certresolver=mytlschallenge

Check for this class of bug across every router in one shot:

docker logs root-traefik-1 2>&1 | grep -iE "nonexistent|unable to obtain ACME"

Defect 2: The Dashboard Was Published to the World

This is the one that matters. The template's port mapping:

ports:
  - "4860"

A bare port with no host binding tells Docker: publish this on all interfaces at a random high port. Docker picked 32769:

$ docker ps --format "{{.Names}}\t{{.Ports}}"
hermes-agent-xlzg-hermes-agent-1   0.0.0.0:32769->4860/tcp, [::]:32769->4860/tcp

0.0.0.0 is not localhost. Confirmed against the box's own public IP:

$ curl -s -o /dev/null -w "code=%{http_code} redir=%{redirect_url}\n" \
    http://72.60.104.157:32769/
code=302 redir=http://72.60.104.157:32769/login?next=%2F

A login page, reachable from the public internet, over plain HTTP. The entrypoint wires basic auth from your env file:

export HERMES_DASHBOARD_BASIC_AUTH_USERNAME="$ADMIN_USERNAME"
export HERMES_DASHBOARD_BASIC_AUTH_PASSWORD="$ADMIN_PASSWORD"
gosu hermes hermes dashboard --host 0.0.0.0 --port 4860 --no-open --skip-build

Basic auth over HTTP means the password crosses the wire base64-encoded, which is encoding, not encryption. Any hop between client and server reads it in cleartext. And the account it protects can run shell commands.

No host firewall was catching this either:

$ ufw status
Status: inactive

$ iptables -L DOCKER -n
ACCEPT  6  --  0.0.0.0/0  172.20.0.2  tcp dpt:4860    Hermes, from anywhere

Source 0.0.0.0/0 on the Hermes container's port. That's the whole internet, allowed by rule.

Docker punches through ufw. Published ports are inserted into the DOCKER iptables chain, which is evaluated before ufw's rules. Turning ufw on would not have closed port 32769. You have to fix the binding.

Fix: don't publish the port at all. Traefik reaches the container over the Docker network; the host doesn't need a mapping.

# Delete the ports: block entirely. Or if you need local access:
ports:
  - "127.0.0.1:4860:4860"

Verify nothing is listening on a public interface afterward:

$ ss -tlnp | grep '0.0.0.0'
# Only 22, 80, 443 should appear.

The upstream image would have refused to do this. Nous ships the dashboard bound to 127.0.0.1 by default, and their docs are explicit that it "fails closed at startup" if you bind a non-loopback address without configuring authentication. Their own compose file carries the warning in a comment:

#   - The dashboard service binds to 127.0.0.1 by default. It stores API
#     keys; exposing it on LAN without auth is unsafe. If you want remote
#     access, use an SSH tunnel or put it behind a reverse proxy that
#     adds authentication — do NOT pass --insecure --host 0.0.0.0.

Hostinger's wrapper hardcodes --host 0.0.0.0 in its entrypoint and publishes the port. The safety default was there; the repackaging removed it.

One wrinkle when you switch to the upstream image: 127.0.0.1 inside a container is the container's own loopback, so Traefik can't reach it either. You do need --host 0.0.0.0 — you just must not pair it with a published port. The container network boundary replaces the loopback boundary, and HERMES_DASHBOARD_BASIC_AUTH_* keeps the fail-closed check satisfied:

command: ["hermes", "dashboard", "--host", "0.0.0.0", "--port", "4860", "--no-open"]

Never add --insecure. That's the flag that turns off the auth requirement, and it's the one Nous' compose comment tells you not to combine with 0.0.0.0.


Defect 3: Credentials in a World-Readable File

$ ls -la /docker/hermes-agent-xlzg/.env
-rw-r--r-- 1 root root 108 Jun 12 07:04 .env

Mode 644. Every local user can read the admin password. Same for /root/.env, which is owned by UID 1001 rather than root — a template artifact.

chmod 600 /docker/hermes-agent-xlzg/.env
chown root:root /docker/hermes-agent-xlzg/.env

While you're there — the template ships no password at all if you skip the prompt. Generate a real one:

openssl rand -base64 32

The Corrected Hermes Service

All three fixes applied — this is the Hermes half, as a drop-in replacement for the template's service block:

services:
  hermes-agent:
    image: nousresearch/hermes-agent:0.16.0
    restart: unless-stopped
    command: ["hermes", "dashboard", "--host", "0.0.0.0", "--port", "4860", "--no-open"]
    # No ports: — Traefik reaches 4860 over the docker network.
    networks:
      - root_default
    labels:
      - traefik.enable=true
      - traefik.http.routers.hermes.rule=Host(`hermes.${TRAEFIK_HOST}`)
      - traefik.http.routers.hermes.entrypoints=websecure
      - traefik.http.routers.hermes.tls.certresolver=mytlschallenge   # matches static config
      - traefik.http.services.hermes.loadbalancer.server.port=4860
      # Defense in depth: Traefik-level basic auth in front of Hermes' own
      - traefik.http.middlewares.hermes-auth.basicauth.users=${HTPASSWD}
      - traefik.http.middlewares.hermes-sec.headers.stsSeconds=31536000
      - traefik.http.middlewares.hermes-sec.headers.forceSTSHeader=true
      - traefik.http.middlewares.hermes-sec.headers.contentTypeNosniff=true
      - traefik.http.routers.hermes.middlewares=hermes-auth,hermes-sec
    env_file:
      - .env
    volumes:
      - ./data:/opt/data

networks:
  root_default:
    external: true

Joining root_default — Traefik's network — is the clean way to do this. Cross-network routing happens to work on a single host because Traefik dials the container IP directly and the kernel routes between bridges, but that's an accident of the default iptables policy, not a contract. Put them on the same network and the dependency is explicit.

Generate the HTPASSWD value:

htpasswd -nbB admin 'your-password' | sed -e 's/\$/\$\$/g'

The sed doubles $ so Compose doesn't try to interpolate the bcrypt hash.


Back Up /opt/data — All the State Lives There

One volume holds everything: config, credentials, agent memory, cron jobs, session history.

$ ls /docker/hermes-agent-xlzg/data
config.yaml     auth.json      kanban.db     memories/
.ssh/           cron/          logs/         .hermes/

auth.json and .ssh/ are the sharp ones — provider tokens and SSH keys the agent uses to reach other machines. Back the directory up encrypted, and never commit it:

tar czf - -C /docker/hermes-agent-xlzg data \
  | gpg --symmetric --cipher-algo AES256 \
  > "hermes-$(date +%F).tar.gz.gpg"

Hermes writes config.yaml.bak.<timestamp> on every config change, so that directory grows. Prune it occasionally.


Two More Things to Tighten

Traefik's API is in insecure mode. --api.insecure=true serves the dashboard on :8080 with no auth. Port 8080 isn't published to the host, so it's not on the internet — but any container on root_default can read your full routing table. Turn it off in production, or route it through the same basic-auth middleware.

Everything is on :latest. Both traefik and the template's :latest tag float. A docker compose pull can hand you a breaking change with no warning. Even a version tag like 0.16.0 can be re-pushed — resolve it to a digest and pin that:

docker inspect nousresearch/hermes-agent:0.16.0 \
  --format '{{index .RepoDigests 0}}'

Also worth knowing: Hermes self-reports update status, so you can check without pulling.

$ cat /docker/hermes-agent-xlzg/data/.update_check
{"ts": 1784592362.1054027, "behind": 1, "rev": null, "ver": "0.16.0"}

Pre-Flight Checklist

Run these before you call a Hermes deploy done:

  • docker inspect <container> --format '{{.Config.Image}}'nousresearch/hermes-agent, not a repackaged wrapper
  • ss -tlnp | grep '0.0.0.0' — only 22, 80, 443 listed
  • docker ps --format "{{.Names}}\t{{.Ports}}" — no app container publishing 0.0.0.0
  • docker logs root-traefik-1 2>&1 | grep -i nonexistent — empty
  • acme.json contains a cert whose main matches your Hermes hostname
  • curl -I https://hermes.example.com returns 401, not 200
  • ls -la .env shows -rw-------
  • Admin password is 32+ random chars, not the template default
  • /opt/data is in an encrypted backup with a tested restore
  • Image tags pinned to digests
  • --api.insecure=true removed from the Traefik command

Summary

Defect Symptom Fix
Repackaged wrapper image Upstream's safety defaults stripped out Run nousresearch/hermes-agent
certresolver=letsencrypt undefined Self-signed cert served; ERR in logs for weeks Match the resolver name in the static config
ports: - "4860" Dashboard on 0.0.0.0:32769, plain HTTP, public Drop ports:; route through Traefik only
.env mode 644 Admin password readable by any local user chmod 600
--api.insecure=true Routing table readable by any co-networked container Remove, or put behind auth
:latest tags Unannounced breaking upgrades Pin digests

The Complete Stack

Traefik and Hermes in one file. Because both services live in the same project, Compose puts them on the same default network automatically — no external: true plumbing, and the cross-network accident from Defect 2 can't happen.

# docker-compose.yaml
services:
  traefik:
    image: traefik@sha256:171c9c3565b29f6c133f1c1b43c5d4e5853415198e9e1078c001f8702ff66aec  # 3.6.12
    restart: always
    command:
      # API on, insecure dashboard OFF — reachable via `docker exec` only
      - "--api=true"
      - "--api.insecure=false"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      # HTTP always redirects to HTTPS
      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--entrypoints.websecure.address=:443"
      # Resolver name must match the router label below, exactly
      - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true"
      - "--certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}"
      - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
      - "--log.level=INFO"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - traefik_data:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro

  hermes:
    # Upstream image, not the VPS template's wrapper.
    # Resolve to a digest before you commit this:
    #   docker inspect nousresearch/hermes-agent:0.16.0 --format '{{index .RepoDigests 0}}'
    image: nousresearch/hermes-agent:0.16.0
    restart: unless-stopped
    # Bind 0.0.0.0 *inside* the container — never with a published port, never with --insecure.
    command: ["hermes", "dashboard", "--host", "0.0.0.0", "--port", "4860", "--no-open"]
    depends_on:
      - traefik
    # No ports: block. Traefik reaches :4860 over the project network.
    labels:
      - traefik.enable=true
      - traefik.http.routers.hermes.rule=Host(`${HERMES_HOST}`)
      - traefik.http.routers.hermes.entrypoints=websecure
      - traefik.http.routers.hermes.tls=true
      - traefik.http.routers.hermes.tls.certresolver=mytlschallenge
      - traefik.http.services.hermes.loadbalancer.server.port=4860
      # Basic auth at the edge, in front of Hermes' own login
      - traefik.http.middlewares.hermes-auth.basicauth.users=${HTPASSWD}
      # HSTS + sniffing protection
      - traefik.http.middlewares.hermes-sec.headers.stsSeconds=31536000
      - traefik.http.middlewares.hermes-sec.headers.forceSTSHeader=true
      - traefik.http.middlewares.hermes-sec.headers.stsIncludeSubdomains=true
      - traefik.http.middlewares.hermes-sec.headers.contentTypeNosniff=true
      - traefik.http.middlewares.hermes-sec.headers.browserXssFilter=true
      - traefik.http.routers.hermes.middlewares=hermes-auth,hermes-sec
    env_file:
      - .env
    volumes:
      - ./data:/opt/data

volumes:
  traefik_data:

The matching .envchmod 600 it before you start anything:

# .env
HERMES_HOST=hermes.example.com
SSL_EMAIL=you@example.com
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<openssl rand -base64 32>
HTPASSWD=<htpasswd -nbB admin 'pw' | sed -e 's/\$/\$\$/g'>

Bring it up:

chmod 600 .env
docker compose up -d
docker compose logs -f traefik | grep -iE "err|certificate"

Then confirm the two things the template got wrong:

# 1. No public listener beyond 22/80/443
ss -tlnp | grep '0.0.0.0'

# 2. A real cert, and auth actually enforced
curl -sI https://hermes.example.com | head -1   # expect: HTTP/2 401

A 401 here is success — it means Traefik's basic auth is challenging you before the request ever reaches Hermes. A 200 means the middleware isn't attached.

Two notes on running this:

  • Digests will go stale. Re-resolve them with docker inspect <image> --format '{{index .RepoDigests 0}}' after a deliberate docker compose pull, and commit the new values. That's the point — upgrades become a diff you approve, not a surprise.
  • --api.insecure=false costs you the dashboard. If you want it, add a router for it with the same hermes-auth middleware rather than flipping the flag back.

The lesson isn't "Hostinger's template is bad" — it's that "deployed successfully" only means the container started. Compose reports process state. It has no opinion about whether your TLS works or your admin panel is facing the internet.

Three commands separate a running container from a production one:

ss -tlnp | grep '0.0.0.0'                              # what's actually exposed
docker logs <traefik> 2>&1 | grep -iE "err|nonexistent" # what silently failed
curl -I https://your-host/                              # what a stranger sees

Run them after every deploy. The template that generated this post passed its own build check and still shipped an internet-facing shell.

Discussion

Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.