One gateway, one agent, one messaging platform. That's the shape a Hermes install starts in, and for a single bot it's all you need. But real fleets grow sideways: a Feishu agent for internal ops, a WeChat agent for customers, a Slack agent for the eng team — each with different skills, different cron jobs, different memories, and each one you don't want leaking into the others. The answer isn't three VPSes. It's profile isolation: several Hermes gateways on one host, each rooted in its own HERMES_HOME, containerized with Docker, routed behind one sun-port, and versioned with Git. This tutorial builds that fleet from scratch.
HERMES_HOME profiles, Docker containers, a sun-port route table, and a Git-tracked config directory — and the ✓ VERIFIED badge means actual execution, not copy-paste from a README.A Hermes profile is not just a login — it's a self-contained workspace. Each profile carries its own:
skills/ — reusable procedures the agent loads on demand. A finance agent's skills have nothing in common with a support agent's.cron/ — scheduled jobs. You don't want the support agent inheriting the finance agent's 2am reconciliation run.memories/ — durable facts injected into every turn. Cross-contaminating these is how an agent starts answering a customer with the CEO's preferences.plugins/ and config — messaging platform credentials, model/provider settings, and gateway state.The moment two logically separate agents share one profile, they share all of that — and one misfiring cron job or one leaked memory is all it takes to make a mess you can't untangle. Profile isolation is the fix: give each agent its own HERMES_HOME and let them run side by side without ever seeing each other's state.
Hermes reads its home directory from the HERMES_HOME environment variable. The default is ~/.hermes; a profile lives at ~/.hermes/profiles/<NAME>. Point a gateway process at a profile directory and it loads that profile's .env, config.yaml, gateway.pid, and gateway_state.json — completely separate from the default profile's.
$ ls ~/.hermes
config.yaml .env skills/ cron/ memories/ plugins/ gateway.pid gateway_state.json
$ ls ~/.hermes/profiles/finance
.env config.yaml skills/ cron/ memories/ gateway.pid gateway_state.json
Two profiles, two gateway.pid files, two gateway_state.json files — no collision, because each process is chrooted into its own directory tree by way of HERMES_HOME.
FEISHU_APP_ID — are inherited by every gateway you launch, regardless of profile. This is the trap that breaks most multi-profile setups, and section 6 is entirely about closing it.$ docker --version
Docker version 27.3.1, build ce12230
$ git --version
git version 2.46.0
$ ls ~/.hermes/profiles
finance support sales
You need a working single-profile Hermes install first (see our Ubuntu walkthrough) and a running sun-port (see the reverse-proxy tutorial). The patterns here also pair naturally with the Docker deployment patterns we covered earlier.
Start with the directory skeleton. A profile needs an .env and a config.yaml at minimum; skills, cron, and memories start empty and fill up as the agent works:
$ mkdir -p ~/.hermes/profiles/finance/{skills,cron,memories,logs}
$ touch ~/.hermes/profiles/finance/.env
$ touch ~/.hermes/profiles/finance/config.yaml
If you already have a profile you want to duplicate (a support agent that's a good starting point for a sales agent), copy the directory and then prune — never clone blindly, or you'll carry over memories and cron you didn't mean to:
$ cp -a ~/.hermes/profiles/support ~/.hermes/profiles/sales
$ rm -rf ~/.hermes/profiles/sales/{memories/*,cron/*}
$ rm -f ~/.hermes/profiles/sales/gateway.pid ~/.hermes/profiles/sales/gateway_state.json
gateway.pid pointing at a live process, or a stale gateway_state.json, will make the new gateway think it's already running (or send it a stop signal meant for the other instance). Delete both before first launch.Here's the trap. Hermes loads the main ~/.hermes/.env first (with override=True), then the profile's .env second (without override). So if your main profile is wired for Feishu, those variables are already in the process environment by the time your WeChat profile starts — and it will happily connect to Feishu instead.
The fix is two-pronged. First, the profile's .env must explicitly empty the Feishu variables:
# ~/.hermes/profiles/finance/.env
FEISHU_APP_ID=
FEISHU_APP_SECRET=
TZ=Asia/Shanghai
NODE_PATH=/usr/lib/node_modules
The empty FEISHU_APP_ID= and FEISHU_APP_SECRET= are not cosmetic — they exist so the profile's own .env has something to load that isn't the inherited Feishu value.
Next, the profile's config.yaml. Two things matter: the target platform must be explicitly enabled (the default for non-Feishu platforms is false), and Feishu must be explicitly disabled:
# ~/.hermes/profiles/finance/config.yaml
model:
default: deepseek-chat
provider: deepseek
platforms:
weixin:
enabled: true # MUST be explicit! Default is false
token: "your-token-here"
feishu:
enabled: false
feishu.enabled: false alone is not enough. The gateway's config loader checks os.getenv("FEISHU_APP_ID") after it reads the YAML, so an inherited environment variable overrides your false. You need BOTH the YAML flag and the environment stripping from section 7. This is the single most common "my second gateway won't start" failure.The instinct is to launch with a clean environment — env -i. Don't. It strips PATH and HOME too, and your gateway dies in a pile of cryptic import and socket errors. Instead, strip only the messaging-platform variables, at the Python level, right before the gateway boots:
#!/bin/bash
# /usr/local/bin/finance-gateway
cd /root/.hermes/hermes-agent || exit 1
HERMES_HOME=/root/.hermes/profiles/finance \
GATEWAY_ALLOW_ALL_USERS=true \
TZ=Asia/Shanghai \
FEISHU_APP_ID="" \
FEISHU_APP_SECRET="" \
PYTHONUNBUFFERED=1 \
./venv/bin/python3 -c "
import os, sys
for k in list(os.environ):
if k.startswith('FEISHU_') or k.startswith('LARK_APP_') or k.startswith('LARK_BOT_'):
del os.environ[k]
os.environ['HERMES_HOME'] = os.path.expanduser('~/.hermes/profiles/finance')
os.environ['FEISHU_APP_ID'] = ''
os.environ['FEISHU_APP_SECRET'] = ''
sys.path.insert(0, '.')
from gateway.run import main
import asyncio
asyncio.run(main())
" 2>&1 | tee -a /root/.hermes/profiles/finance/logs/gateway.log &
What this does: it strips every FEISHU_/LARK_ variable from the process's own environment, re-points HERMES_HOME at the profile, and boots gateway.run:main — all while leaving PATH, HOME, and NODE_PATH intact. Targeted stripping beats a clean slate every time.
$ ps aux | grep 'gateway.run' | grep -v grep
root 8123 ... ./venv/bin/python3 -c ... HERMES_HOME=/root/.hermes ... (primary)
root 8177 ... ./venv/bin/python3 -c ... HERMES_HOME=/root/.hermes/profiles/finance ...
$ cat ~/.hermes/gateway_state.json # primary (Feishu)
$ cat ~/.hermes/profiles/finance/gateway_state.json # finance (WeChat)
Two separate PIDs, two separate state files, two separate platforms. If gateway_state.json for the finance profile shows Feishu, the env stripping didn't take — re-check section 5 and 7.
Running gateways as bare processes works, but Docker gives you restart policy, resource limits, and a clean per-profile namespace. Each profile gets its own container, mounting only its own HERMES_HOME:
# docker-compose.yml — one service per profile
services:
gateway-primary:
image: hermes-agent:latest
restart: unless-stopped
environment:
HERMES_HOME: /root/.hermes
FEISHU_APP_ID: ""
FEISHU_APP_SECRET: ""
volumes:
- /root/.hermes:/root/.hermes
ports:
- "127.0.0.1:4100:4100"
gateway-finance:
image: hermes-agent:latest
restart: unless-stopped
environment:
HERMES_HOME: /root/.hermes/profiles/finance
FEISHU_APP_ID: ""
FEISHU_APP_SECRET: ""
volumes:
- /root/.hermes/profiles/finance:/root/.hermes/profiles/finance
ports:
- "127.0.0.1:4101:4101"
Notice the port binding to 127.0.0.1 — the same discipline from our blue-green tutorial. No gateway is publicly reachable; only sun-port faces the internet. Each profile mounts its own directory, so a finance container physically cannot read the primary profile's memories or cron.
$ docker compose up -d
$ docker compose ps
NAME STATUS
gateway-primary Up (healthy)
gateway-finance Up (healthy)
Multiple gateways, one reverse proxy. sun-port maps each external hostname to the right internal port — and terminates TLS once, so neither gateway has to care:
# sun-port config
server {
listen 443 ssl;
server_name agent.example.com;
ssl_certificate /etc/letsencrypt/live/agent.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/agent.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:4100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 443 ssl;
server_name finance.example.com;
ssl_certificate /etc/letsencrypt/live/finance.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/finance.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:4101;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
$ sun-port -t # validate
configuration file is valid
$ sun-port -s reload # hot reload — no dropped connections
reload signal sent
Two public hostnames, two isolated profiles, one proxy — and adding a third agent is a new server block plus a new container, nothing else.
Profiles do not inherit model.*, fallback_providers, or API keys from each other — each has its own. When you stand up a new profile by copying an old one, three stale bits routinely break it:
api_mode: chat_completions from an OpenRouter setup silently breaks Anthropic-compatible providers.fallback_providers entry pointing at OpenRouter makes the new profile mask its own config and return 400s.The reliable fix is to copy the model/provider block explicitly and verify it, rather than trusting a directory copy:
$ # read the source profile's model config
$ grep -A 12 '^model:' ~/.hermes/config.yaml
$ # paste the same block into the target, then confirm:
$ HERMES_HOME=~/.hermes/profiles/finance hermes config get model.provider
deepseek
sk-...), which means shell quoting and tool logs will mangle them. When you move a key between profiles, use a base64 relay rather than pasting the raw string — encode locally, decode into the target file, never echo it to the terminal.Profiles are config, and config belongs in Git. Track each profile's config.yaml, .env (with secrets redacted or gitignored), and the launch scripts — but never commit memories/ or runtime state:
# .gitignore
.env
gateway.pid
gateway_state.json
memories/
logs/
*.log
$ git init ~/.hermes/profiles
$ cd ~/.hermes/profiles
$ git add finance/ support/ sales/
$ git commit -m "fleet: add finance, support, sales gateway profiles"
$ git push origin main
Now a new host can be brought up with git clone plus one docker compose up -d — the same reproducibility discipline from our skills Git workflow.
Profiles are isolated on purpose — but sometimes two agents legitimately share one source of truth, like a customer database or a job queue. That's what PostgreSQL is for: keep it outside any single profile and let both connect:
# shared database, owned by neither profile
$ docker run -d --name fleet-db \
-e POSTGRES_DB=fleet -e POSTGRES_USER=agent \
-e POSTGRES_PASSWORD="${DB_PASSWORD}" \
-v fleet-pgdata:/var/lib/postgresql/data \
postgres:16
The principle is clean: memories and cron are per-profile; durable business state is shared. An im-bot room that both the support and finance agents join reads its message history from this database, not from either agent's HERMES_HOME. If you need embeddings on top, the pgvector tutorial covers that.
env -i. It strips PATH/HOME and causes mysterious import and socket failures. Strip only the messaging-platform variables, at the Python level..env loads with override=True, and config.py reads os.getenv() after YAML. Handle both: empty .env values + Python-level strip.enabled: true. Non-Feishu platforms default to false; your WeChat gateway silently never connects.platforms: keys. Two platforms: sections in one YAML file — the parser keeps only the last one.0.0.0.0. Each profile is then publicly reachable, bypassing sun-port's TLS. Bind 127.0.0.1.api_mode/fallback_providers from a previous provider breaks the new profile with 400s. Re-point model config explicitly.HERMES_HOME gives each agent its own skills, cron, memories, and platform — no VPS-per-agent tax.FEISHU_* variables cross profiles unless you empty them in .env and strip them at the Python level. Never env -i.enabled: true/false for every platform; never rely on defaults.127.0.0.1; one reverse proxy owns TLS and routing.config.yaml, .env, and launch scripts; ignore memories/, PID, and state files.Profile isolation turns a single Hermes host into a fleet: a Feishu ops agent, a WeChat support agent, a Slack eng agent — each with its own skills, cron, and memories, all behind one sun-port, all reproducible from Git. For an operation already running im-bot rooms and PostgreSQL state through Docker, that's the difference between one agent and a product line.