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 carrytraefik.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:
My Traefik defines exactly one resolver, and it isn't named letsencrypt:
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.
Check for this class of bug across every router in one shot:
Defect 2: The Dashboard Was Published to the World¶
This is the one that matters. The template's port mapping:
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
DOCKERiptables 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.
Verify nothing is listening on a public interface afterward:
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:
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¶
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.
While you're there — the template ships no password at all if you skip the prompt. Generate a real one:
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:
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:
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 publishing0.0.0.0 -
docker logs root-traefik-1 2>&1 | grep -i nonexistent— empty -
acme.jsoncontains a cert whosemainmatches your Hermes hostname -
curl -I https://hermes.example.comreturns401, not200 -
ls -la .envshows-rw------- - Admin password is 32+ random chars, not the template default
-
/opt/datais in an encrypted backup with a tested restore - Image tags pinned to digests
-
--api.insecure=trueremoved 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 .env — chmod 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:
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 deliberatedocker compose pull, and commit the new values. That's the point — upgrades become a diff you approve, not a surprise. --api.insecure=falsecosts you the dashboard. If you want it, add a router for it with the samehermes-authmiddleware 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.