In the previous post I laid out why the $20-30 tier is the sweet spot for personal AI agents: self-host the loop (Hermes), subscribe to the model (Claude Code, Gemini Tasks, or metered API). The missing piece was the how — how to actually wire a self-hosted Hermes instance to a model router that gives you free tiers and cheap paid tokens without juggling ten API keys.
This post is that how-to. It’s based on the stack running on my host today: Hermes Agent + OmniRoute + Redis in a docker compose file, with OmniRoute configured to route requests through DeepSeek (paid) and a 13-step free-tier cascade across 6 providers. All on a private Docker network, no public exposure, Matrix as the capture surface.
Note: The compose file and configs below are a trimmed version of what I run. The deployed stack also includes an API gateway, a per-profile secret-entry listener for each profile, and a Gitea webhook relay — omitted here for clarity. The core four services (Redis, OmniRoute, Hermes, browser) are byte-identical.
The architecture
+-----------------------------------------------------------------+
| Your Host (your-host) |
| +--------------+ +--------------+ +------------------+ |
| | Hermes |--->| OmniRoute |--->| Redis | |
| | (agent) | | (gateway) | | (rate limit/ | |
| | port 9119 | | port 20128 | | cache) | |
| +--------------+ +--------------+ +------------------+ |
| | | |
| | v |
| | +--------------+ |
| | | Providers | |
| | | (external) | |
| | | * DeepSeek | |
| | | * NIM | |
| | | * OpenRouter| |
| | | * Z.AI | |
| | | * Cloudflare| |
| | | * Gemini | |
| | | * Mistral | |
| | +--------------+ |
| v |
| +--------------+ |
| | Matrix | |
| | (Tuwunel) | |
| +--------------+ |
+-----------------------------------------------------------------+Key design decisions:
- Private network only —
aitools_internalis an internal Docker network. OmniRoute binds to your host’s LAN IP (e.g.,192.168.x.x:20128), never0.0.0.0. No Traefik route, no port forwarding. Hermes reaches OmniRoute athttp://omniroute:20128/v1over the container name. - Redis is used for distributed rate limiting and shared cache. The compose includes a tuned Redis sidecar (128MB maxmemory, LRU eviction).
- Secrets in .env, not the compose file — JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY, INITIAL_PASSWORD, OMNIROUTE_WS_BRIDGE_SECRET, and the OmniRoute API key for Hermes all live in a
.envfile that the compose file references. - Require API key on every request —
REQUIRE_API_KEY=truemeans Hermes must present an OmniRoute key. The key is pinned to a specific routing combo (see below).
The docker compose file
Save this as docker-compose.yml on your Docker host. The ${VAR} placeholders come from a .env file in the same directory (see the .env.example section below).
# ──────────────────────────────────────────────────────────────────────
# Hermes Agent + OmniRoute + Redis — single-host, LAN-only (trimmed)
# Tested: Hermes v0.22.5, OmniRoute 3.8.52, Redis 8.6.7-alpine
# ──────────────────────────────────────────────────────────────────────
services:
omniroute-redis:
image: docker.io/library/redis:8.6.7-alpine@sha256:7033ad495c19b6bca38aea38f9130d1a3d5b647ac94f3ebe644c8c0fb804e039
container_name: omniroute-redis
restart: unless-stopped
command:
- "redis-server"
- "--save"
- "60"
- "1"
- "--appendonly"
- "no"
- "--maxmemory"
- "128mb"
- "--maxmemory-policy"
- "allkeys-lru"
volumes:
- ./data/omniroute-redis:/data
networks: [aitools_internal]
# no ports: never publish (no requirepass)
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 3
mem_limit: 192m
cpus: 0.25
security_opt: ["no-new-privileges:true"]
omniroute:
image: docker.io/diegosouzapw/omniroute:3.8.52@sha256:b08a12e876baa8a5ccadd1ebbe346fe7267dba3b1c9f4fd6bb02070fae41ab6c
container_name: omniroute
restart: unless-stopped
stop_grace_period: 40s # SQLite WAL checkpoint (upstream guidance)
depends_on:
omniroute-redis:
condition: service_healthy
environment:
# secrets from .env (only the OmniRoute ones are passed in)
JWT_SECRET: ${JWT_SECRET}
API_KEY_SECRET: ${API_KEY_SECRET}
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
INITIAL_PASSWORD: ${INITIAL_PASSWORD}
OMNIROUTE_WS_BRIDGE_SECRET: ${OMNIROUTE_WS_BRIDGE_SECRET}
PORT: "20128"
HOSTNAME: "0.0.0.0"
DATA_DIR: /app/data
REDIS_URL: redis://omniroute-redis:6379
OMNIROUTE_MEMORY_MB: "1536" # chat-completions only (Hermes); /v1/responses coding agents need 8192+
REQUIRE_API_KEY: "true" # every /v1 call needs an OmniRoute key (Hermes gets its own)
AUTH_COOKIE_SECURE: "${OMNIROUTE_COOKIE_SECURE:-false}" # true only if dashboard is served via Traefik/HTTPS
NEXT_PUBLIC_BASE_URL: "${OMNIROUTE_PUBLIC_URL:-http://YOUR_LAN_IP:20128}"
TZ: ${TZ}
volumes:
- ./data/omniroute:/app/data
networks: [aitools_internal]
ports:
# LAN-only: bind to your host's LAN IP, never 0.0.0.0, no public Traefik route by default.
# Router must NOT port-forward 20128.
- "YOUR_LAN_IP:20128:20128"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 60s
timeout: 15s
retries: 5
start_period: 60s
mem_limit: 2560m
cpus: 1.5
security_opt: ["no-new-privileges:true"]
labels:
- homepage.group=AI
- homepage.name=OmniRoute
- homepage.href=http://YOUR_LAN_IP:20128
hermes:
image: docker.io/nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
container_name: hermes
restart: unless-stopped
command: gateway run # s6 supervises gateway(s) per profile + dashboard
stop_grace_period: 60s
depends_on:
omniroute:
condition: service_started
environment:
HERMES_UID: "1000" # match your host UID for the bind mount
HERMES_GID: "1000"
HERMES_DASHBOARD: "1"
HERMES_DASHBOARD_HOST: "0.0.0.0"
HERMES_DASHBOARD_PORT: "9119"
HERMES_DASHBOARD_PUBLIC_URL: "https://hermes.${DOMAINNAME}"
# Enable browser tools (web search, auth flows) — requires the optional 'browser' service below
BROWSER_CDP_URL: "http://browser.localhost:9222"
# Dashboard auth gate is mandatory on non-loopback binds. Basic auth inside + Authelia in front.
HERMES_DASHBOARD_BASIC_AUTH_USERNAME: ${HERMES_DASHBOARD_BASIC_AUTH_USERNAME}
# Password: scrypt hash in /opt/data/config.yaml (host only). Do NOT set HERMES_DASHBOARD_BASIC_AUTH_PASSWORD (env plaintext wins over the hash).
HERMES_DASHBOARD_BASIC_AUTH_SECRET: ${HERMES_DASHBOARD_BASIC_AUTH_SECRET}
# API server (8642) is gated by API_SERVER_KEY in v0.22.5, not API_SERVER_ENABLED.
# The example below does not set a key, so the server stays off.
TZ: ${TZ}
# Provider keys, Matrix tokens etc. go in /opt/data/.env and /opt/data/profiles/<p>/.env (NOT here).
volumes:
- ./data/hermes:/opt/data
# If you use secure-env-ingress for secret entry, mount its LAN cert:
# - ./data/hermes/profiles/your-paid-profile/.senv/senv-cert.pem:/etc/ssl/certs/bbbbc909.0:ro
# Generate: docker run --rm -v ./data/hermes/profiles/your-paid-profile/.senv:/out ghcr.io/omaramin/secure-env-ingress:latest gencert --host YOUR_LAN_IP
# NO docker socket. NO ~/.ssh. Terminal backend = local (inside this container only).
networks: [aitools_internal]
ports:
- "YOUR_LAN_IP:9443:9443" # secure-env-ingress HTTPS form (LAN only, bearer one-use link)
- "YOUR_LAN_IP:9119:9119" # dashboard -> Traefik (reverse proxy) with Authelia
# 8642 intentionally NOT published (no API_SERVER_KEY set)
healthcheck:
test: ["CMD", "/opt/hermes/.venv/bin/python", "-c", "import socket; socket.create_connection(('127.0.0.1', 9119), 5)"]
interval: 60s
timeout: 15s
retries: 3
start_period: 120s
mem_limit: 3g
cpus: 2.0
pids_limit: 2048 # 512 was hit on 30 Oct 2026 (Chromium threads); deployed uses 2048
ulimits:
core: 0 # prevent 1+ GB crash dumps from chrome_crashpad
security_opt: ["no-new-privileges:true"]
labels:
- homepage.group=AI
- homepage.name=Hermes
- homepage.href=https://hermes.yourdomain
# Optional: shared headless Chromium for browser tools (web search, auth flows)
# Hermes v0.22.5 attaches via BROWSER_CDP_URL = http://browser.localhost:9222
# The ".localhost" alias matters: Chrome's DevTools HTTP server rejects any Host header that is not an IP or localhost/*.localhost
browser:
image: docker.io/chromedp/headless-shell:157.0.8103.15@sha256:f76fce2964611eb1e25b90d906727884db12a8918a6d85a0df2619a9ef845c7c
container_name: browser
restart: unless-stopped
init: true
user: "1000:1000"
command:
- --user-data-dir=/data/profile
- --no-first-run
- --no-default-browser-check
- --disable-crash-reporter
- --lang=en-US
environment:
HOME: /data
TZ: ${TZ}
volumes:
- ./data/browser:/data
networks:
aitools_internal:
aliases: [browser.localhost]
# NO ports: CDP = full control of the browser and its logged-in sessions. No Traefik route.
shm_size: 1gb
mem_limit: 2g
cpus: 1.5
pids_limit: 2048
security_opt: ["no-new-privileges:true"]
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/9222 && printf 'GET /json/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && grep -q webSocketDebuggerUrl <&3"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
networks:
aitools_internal:
name: aitools_internal
internal: false # hermes/omniroute need egress to LLM providers + MatrixThe .env.example file
Create a .env file alongside the compose file with these variables. Never commit the real .env to git.
# ──────────────────────────────────────────────────────────────────────
# Hermes + OmniRoute .env — copy to .env and fill in
# ──────────────────────────────────────────────────────────────────────
# Timezone (Asia/Dubai for me)
TZ=Asia/Dubai
# Your base domain for public URLs (used in Hermes dashboard public URL)
DOMAINNAME=yourdomain
# ────────── OmniRoute secrets (generate with: openssl rand -base64 32) ──────────
JWT_SECRET= # 32+ chars, used for JWT signing
API_KEY_SECRET= # 32+ chars, encrypts stored provider keys
STORAGE_ENCRYPTION_KEY= # 32+ chars, encrypts SQLite database
INITIAL_PASSWORD= # OmniRoute dashboard initial admin password
OMNIROUTE_WS_BRIDGE_SECRET= # 32+ chars, WebSocket bridge auth
# OmniRoute dashboard cookie security (true only if behind HTTPS/Traefik)
OMNIROUTE_COOKIE_SECURE=false
# Public URL for OmniRoute dashboard (used in OAuth redirects, etc.)
OMNIROUTE_PUBLIC_URL=http://YOUR_LAN_IP:20128
# ────────── Hermes dashboard auth (basic auth + Authelia in front) ──────────
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin
# Generate scrypt hash: docker run --rm nousresearch/hermes-agent:v0.22.5 python -c "import scrypt; print(scrypt.hash(b'YOUR_PASSWORD', scrypt.gensalt()).decode())"
HERMES_DASHBOARD_BASIC_AUTH_SECRET= # 32+ chars, token signing keyGenerate the secrets once:
openssl rand -base64 32 # run this 5 times for the 5 OmniRoute secretsBringing it up
# 1. Create the .env from the example and fill in secrets
cp .env.example .env
# edit .env with your values
# 2. Create data directories with correct ownership (UID 1000)
mkdir -p data/omniroute-redis data/omniroute data/hermes data/browser
chown -R 1000:1000 data/
# 3. Pull images and start
docker compose pull
docker compose up -d
# 4. Verify all containers are healthy
docker compose ps
# Check logs if anything is unhealthy
docker compose logs -f omniroute
docker compose logs -f hermesConfiguring OmniRoute: providers, free tiers, and routing combos
Once the stack is up, open the OmniRoute dashboard at http://YOUR_LAN_IP:20128 (or your LAN IP). Log in with the INITIAL_PASSWORD from your .env.
Step 1: Add providers
Go to Providers in the sidebar. Add providers that suit your needs. The running instance uses these (all have some form of free access; check each provider’s current terms):
| Provider | Notes |
|---|---|
| DeepSeek | Prepaid; no recurring free tier. 5M tokens on signup (one-time). |
| Gemini (Google AI Studio) | Recurring free quota on Flash models. |
| NVIDIA NIM | Free tier on select models (e.g., Nemotron). |
| OpenRouter | Free models via *:free slugs (e.g., nemotron-3-ultra-550b-a55b:free). |
| Z.AI / GLM | Free tier on GLM models. |
| Cloudflare Workers AI | Free tier with Cloudflare account. |
| Mistral | Free tier on select models (e.g., ministral-14b-latest). |
For each: click Add Provider, choose the provider, paste the API key, save. OmniRoute encrypts the keys at rest using STORAGE_ENCRYPTION_KEY.
Note: Free-tier details change. Check each provider’s current pricing page and OmniRoute’s Free Tiers Guide before relying on specific quotas.
Step 2: Create routing combos
OmniRoute routes requests through combos — named routing policies that define which providers to try, in what order, and with what strategy. Go to Combos and create these two:
hermes-agent-private (paid DeepSeek only)
- Strategy:
priority - Providers:
deepseek(only) - Model:
deepseek/deepseek-v4-flash(forced viaforcedModelon the API key) - Purpose: The paid tier for profiles that handle sensitive data (your paid-tier profiles). This combo excludes all free tiers — it’s a hard privacy boundary.
hermes-agent (free tier cascade)
- Strategy:
priority - Providers (13 steps across 6 providers, in order):
nvidia—nemotron-3-ultra-550b-a55bgemini—gemini-3.8-flashopenrouter—nemotron-3-ultra-550b-a55b:freezai—glm-4.7-flashcloudflare-ai—@cf/zai-org/glm-4.7-flashgemini—gemini-3-flash-previewopenrouter—nemotron-3.5-super-vl:freegemini—gemini-3.6-flashmistral—ministral-14b-latestgemini—gemini-3.5-flash-liteopenrouter—laguna-s-2.1:freenvidia—minimax-m3.1-flashopenrouter—gemma-4-31b-it:free
- Purpose: General-purpose agent work where free tiers are fine. No DeepSeek step — this is a “FREE ONLY” combo by design. If a step is priced in the gateway’s rate table (e.g.,
gemini-3.8-flashshows $0.75/$3.75), the gateway may still estimate a cost, but no vendor bill is generated for the free-tier models.
Step 3: Create API keys for Hermes
Go to Endpoints → Create API Key. Create one key per tier, not per profile — several profiles can share a key:
| Key name | Pinned combo | Spend limit | Used by profiles |
|---|---|---|---|
your-paid-key | hermes-agent-private | (not set) | your paid-tier profiles (e.g., your-paid-profile, your-other-paid-profile) |
your-free-key | hermes-agent | (not set) | your free-tier profiles (e.g., your-free-profile, your-other-free-profile, …) |
Important: When creating the key, set Pinned Combo to the combo name. This forces every request using that key through that specific routing policy. The key cannot access other combos.
Note: The key named
your-free-keyis pinned to the free combo (hermes-agent). The paid profiles useyour-paid-keypinned tohermes-agent-private. The pin is what enforces the tier, not the key name — a wrong pin looks like success (HTTP 200) while a different provider answers.
Copy each key — you’ll need them in the Hermes profile .env files.
Configuring Hermes: pointing at OmniRoute
Hermes treats OmniRoute as a custom OpenAI-compatible endpoint. The config lives in each profile’s config.yaml (under /opt/data/profiles/<profile>/config.yaml in the container, or ./data/hermes/profiles/<profile>/config.yaml on the host).
Profile using paid DeepSeek only (your paid-tier profiles)
# /opt/data/profiles/your-paid-profile/config.yaml
providers:
omniroute:
api: http://omniroute:20128/v1 # internal Docker network; never via Traefik
key_env: OMNIROUTE_API_KEY # OmniRoute key "your-paid-key", PINNED to hermes-agent-private
discover_models: false
models:
- hermes-agent-private # paid-only combo: deepseek/deepseek-v4-flash
model:
default: hermes-agent-private
provider: custom:omniroute
# If OmniRoute itself is down/erroring, go straight to DeepSeek (small prepaid balance = hard cap)
fallback_providers:
- provider: deepseek # uses DEEPSEEK_API_KEY from profile .env
model: deepseek-flashIn the profile’s .env:
OMNIROUTE_API_KEY=omr_... # the "your-paid-key" key from OmniRoute Endpoints page
DEEPSEEK_API_KEY=sk-... # direct DeepSeek key for fallback (optional but recommended)
MATRIX_HOMESERVER=http://YOUR_MATRIX_HOMESERVER:8008
MATRIX_ACCESS_TOKEN=...
MATRIX_USER_ID=@your-paid-bot:matrix.yourdomain
MATRIX_DEVICE_ID=YOUR_PAID_DEVICE
MATRIX_E2EE_MODE=optional
MATRIX_RECOVERY_KEY=...Note: If you have multiple paid profiles, they can share this config and key. Add
DEEPSEEK_API_KEYto each profile’s.envif you want the direct fallback to work.
Profile using free tier cascade (your free-tier profiles)
# /opt/data/profiles/your-free-profile/config.yaml
providers:
omniroute:
api: http://omniroute:20128/v1
key_env: OMNIROUTE_API_KEY # OmniRoute key "your-free-key", PINNED to hermes-agent
discover_models: false
models:
- hermes-agent # free tier cascade: 13 steps across 6 providers
model:
default: hermes-agent
provider: custom:omniroute
context_length: 128000
# No fallback_providers here — if OmniRoute is down, the request errors.
# Add DEEPSEEK_API_KEY to .env and the fallback block above if you want a direct fallback.In the profile’s .env:
OMNIROUTE_API_KEY=omr_... # the "your-free-key" key from OmniRoute Endpoints page
# NO DEEPSEEK_API_KEY — this profile stays on free tiers only
MATRIX_HOMESERVER=http://YOUR_MATRIX_HOMESERVER:8008
MATRIX_ACCESS_TOKEN=...
MATRIX_USER_ID=@your-free-bot:matrix.yourdomain
MATRIX_DEVICE_ID=YOUR_FREE_DEVICE
MATRIX_E2EE_MODE=optional
MATRIX_RECOVERY_KEY=...Creating the Hermes profiles
# Enter the running Hermes container
docker exec -it hermes bash
# Create each profile
hermes profile create your-paid-profile
hermes profile create your-free-profile
hermes profile create your-other-free-profile
# ... add others as needed
# Validate each
hermes -p your-paid-profile doctor
hermes -p your-free-profile doctor
hermes -p your-other-free-profile doctorThe doctor command diagnoses issues with Hermes Agent setup. With --live it can probe tool backends; without, it checks config and static connectivity.
Testing the chain
With everything running, test from the Hermes container:
# Test OmniRoute connectivity from inside Hermes container
docker exec -it hermes bash -c 'curl -s http://omniroute:20128/v1/models -H "Authorization: Bearer $OMNIROUTE_API_KEY" | head -c 400'
# Test a chat request through the Hermes profile
docker exec -it hermes hermes -p your-free-profile chat --oneshot "Say hello in one sentence"Watch the OmniRoute dashboard — you should see the request arrive, which provider it routed to, latency, and estimated cost.
Matrix as the capture surface
This setup uses Matrix (e.g., Synapse or Tuwunel) as the capture surface, not Telegram. Each Hermes profile connects to Matrix with its own device ID and access token. The matrix section in config.yaml gates access by user mention — only you and your bot IDs can invoke the agent.
matrix:
thread_require_mention: true
require_mention: true
allowed_users:
- '@you:matrix.yourdomain'
- '@your-paid-bot:matrix.yourdomain'
- '@your-free-bot:matrix.yourdomain'
# ... add your other bot IDs
allowed_rooms: [] # empty = no room restriction; mention gating applies
process_notices: false
session_scope: room
auto_thread: trueTo interact: create a Matrix room, invite @your-paid-bot:matrix.yourdomain (or whatever profile), and mention it: @your-paid-bot what's the status of my stack?
What this costs (rough guide)
| Component | Monthly cost |
|---|---|
| Hardware (already owned) | $0 |
| DeepSeek prepaid balance | Single-digit dollars for light use; tens for a busy estate |
| Free-tier providers (Gemini, NIM, OpenRouter free models, Z.AI, Cloudflare, Mistral) | $0 (no vendor bill on free steps; gateway may still estimate) |
| OmniRoute / Redis / Hermes | $0 (self-hosted) |
Compare to $20-30/month for a managed agent tier, or $200 for business tiers. The trade-off is one Saturday of wiring — after that, you own the control plane, the data, and the routing logic.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Hermes can’t reach http://omniroute:20128/v1 | Containers not on same network | Verify both are on aitools_internal (docker network inspect aitools_internal) |
| OmniRoute dashboard returns 401/403 | REQUIRE_API_KEY=true but no key sent | Ensure Hermes profile has OMNIROUTE_API_KEY in .env and key_env: OMNIROUTE_API_KEY in config |
| “No models available” on custom endpoint | discover_models: false but model not in models: list | Add the combo model name (e.g., hermes-agent-private) to the models: array |
| Free tier step skipped | Provider hit quota or returned error | The cascade combo (hermes-agent) automatically falls through to the next step |
| SQLite “database is locked” on OmniRoute restart | stop_grace_period too short | Keep stop_grace_period: 40s — lets SQLite checkpoint WAL |
hermes chat --once fails | Wrong flag | Use --oneshot instead |
| Thread exhaustion in Hermes container | pids_limit too low | Use 2048 (512 was hit on 30 Oct 2026) |
What’s next
- Add more free providers — Logfare (free key, no rate limits), Kiro AI, Pollinations. Each adds fallback depth.
- Routing policies per task — Use
auto/coding,auto/fast,auto/cheapaliases in OmniRoute for automatic model selection based on the request type. - Cost alerts — OmniRoute’s dashboard shows per-key spend. Set up a cron job in Hermes to query the spend API and alert in Matrix when a key approaches its limit.
- High availability — Current topology is single-replica SQLite. For HA, run N independent OmniRoute processes with separate data dirs (see OmniRoute docs on scale-out).
This post reflects a trimmed version of the stack running on my host as of November 2026. The deployed stack also includes an API gateway, per-profile secret-entry listeners, and a Gitea webhook relay. If you deploy this and hit something different, check the OmniRoute Docker Guide and Hermes Providers docs — both projects move fast.




