Deployed as its own service (pinned nousresearch/hermes-agent:v2026.8.19), own Matrix bot account (@hermes), own OpenRouter-backed model config, and its own OpenAI-compatible API server (internal network only, for possible future use by claude-agent). Joins the same control room but only responds when explicitly @mentioned, restricted to the human user — no conflict with claude-bot's default no-prefix chat routing. claude-agent's router now ignores messages addressed to @hermes so both bots don't answer the same message. Bridge networking (the 'web' network), not the image's default host mode — no reason for an agent container to share the host's network namespace when everything it needs (the homeserver, OpenRouter) is reachable over the existing bridge.
158 lines
7.4 KiB
YAML
158 lines
7.4 KiB
YAML
services:
|
|
# Traefik deliberately does NOT live in this stack — it's shared infra fronting
|
|
# Gitea/Portainer/Matrix/agent (see ~/traefik/docker-compose.yml, a separate,
|
|
# independently-managed stack). It used to be a service here, but a GitOps redeploy
|
|
# tears every service in a stack down before bringing them back up — and claude-agent's
|
|
# image pull goes through Traefik→Gitea's registry, so a self-hosted Traefik ends up
|
|
# briefly tearing down the very route its sibling service needs to come back up.
|
|
# Circular dependency, self-inflicted outage. Don't put Traefik back in this file.
|
|
|
|
matrix-homeserver:
|
|
image: ghcr.io/continuwuity/continuwuity:latest
|
|
container_name: matrix-homeserver
|
|
restart: unless-stopped
|
|
environment:
|
|
CONTINUWUITY_SERVER_NAME: ${MATRIX_SERVER_NAME}
|
|
CONTINUWUITY_DATABASE_PATH: /var/lib/continuwuity
|
|
CONTINUWUITY_ADDRESS: 0.0.0.0
|
|
CONTINUWUITY_PORT: 8008
|
|
# Private control-room bot only — no federation, no open registration.
|
|
# Registration is flipped on temporarily, once, to create the bot account
|
|
# (see README "First boot: Matrix bot account").
|
|
CONTINUWUITY_ALLOW_FEDERATION: "false"
|
|
CONTINUWUITY_ALLOW_REGISTRATION: ${MATRIX_ALLOW_REGISTRATION:-false}
|
|
# CONTINUWUITY_REGISTRATION_TOKEN is deliberately NOT set here: Continuwuity treats
|
|
# a present-but-empty value as a config error (fails to start), so it can only be
|
|
# added here temporarily (with a real value) when you need to register a new user,
|
|
# then removed again — see README "Adding another Matrix user".
|
|
volumes:
|
|
- matrix_data:/var/lib/continuwuity
|
|
networks:
|
|
- web
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.matrix.rule=Host(`${MATRIX_SERVER_NAME}`)"
|
|
- "traefik.http.routers.matrix.entrypoints=websecure"
|
|
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.matrix.loadbalancer.server.port=8008"
|
|
|
|
litellm:
|
|
# Pinned deliberately, not :latest or :main-latest — litellm==1.82.7/1.82.8 on PyPI
|
|
# were compromised with credential-stealing malware in March 2026 (fixed within the
|
|
# hour, but a floating tag could still land on a bad release in the future). v1.98.0
|
|
# verified clean as of this writing.
|
|
image: ghcr.io/berriai/litellm:v1.98.0
|
|
container_name: litellm
|
|
restart: unless-stopped
|
|
environment:
|
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
|
volumes:
|
|
# Absolute host path, NOT a repo-relative one — Portainer's git-stack deploy clones
|
|
# into its own directory (/data/compose/N/) whose checkout doesn't reliably persist
|
|
# for the container's runtime (see the act_runner config comment below for the same
|
|
# failure mode). An absolute path on the actual host filesystem always resolves the
|
|
# same way regardless of which tool ran `docker compose up`. Keep this local clone
|
|
# (/home/william/gitops-automation) pulled to latest when the config changes.
|
|
- /home/william/gitops-automation/litellm-config.yaml:/app/config.yaml:ro
|
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
|
networks:
|
|
- web
|
|
# Internal only — no Traefik labels. No reason to expose an LLM gateway holding a
|
|
# master key and OAuth-forwarding config to the public internet.
|
|
|
|
hermes:
|
|
# Pinned to a specific dated release, not :latest — same rationale as litellm above.
|
|
image: nousresearch/hermes-agent:v2026.8.19
|
|
container_name: hermes
|
|
restart: unless-stopped
|
|
environment:
|
|
HERMES_UID: "1000"
|
|
HERMES_GID: "1000"
|
|
# Internal container address, not the public HTTPS one — same docker network as
|
|
# matrix-homeserver, no reason to round-trip through Traefik/TLS for this.
|
|
MATRIX_HOMESERVER: http://matrix-homeserver:8008
|
|
MATRIX_ACCESS_TOKEN: ${HERMES_MATRIX_ACCESS_TOKEN}
|
|
# Only you can trigger it; and only with an explicit @hermes mention in shared
|
|
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
|
|
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
|
|
MATRIX_REQUIRE_MENTION: "true"
|
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
|
# Exposed on the internal network only (see claude-agent's LITELLM_BASE_URL-style
|
|
# usage pattern) — nothing publishes this port externally.
|
|
API_SERVER_ENABLED: "true"
|
|
API_SERVER_HOST: 0.0.0.0
|
|
API_SERVER_KEY: ${HERMES_API_SERVER_KEY}
|
|
volumes:
|
|
- /home/william/hermes-data:/opt/data
|
|
networks:
|
|
- web
|
|
|
|
claude-agent:
|
|
image: ${GITEA_REGISTRY_IMAGE}
|
|
depends_on:
|
|
- litellm
|
|
container_name: claude-agent
|
|
restart: unless-stopped
|
|
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
|
|
# repo fresh (no .env present, it's gitignored) and only performs ${VAR} substitution
|
|
# from the stack's own Env list, so env_file here would silently fail to load anything.
|
|
environment:
|
|
GITEA_URL: ${GITEA_URL}
|
|
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
|
|
# Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing —
|
|
# Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply.
|
|
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
|
|
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
|
|
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
|
|
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
|
|
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
|
|
KNOWN_REPOS: ${KNOWN_REPOS}
|
|
# All model calls now go through the local litellm service, not OpenRouter directly —
|
|
# one gateway for OpenRouter's models (incl. its auto-router) and, for the
|
|
# claude-subscription route, Anthropic itself via the forwarded OAuth token above.
|
|
LITELLM_BASE_URL: http://litellm:4000
|
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
|
volumes:
|
|
- agent_workspace:/workspace
|
|
networks:
|
|
- web
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.agent.rule=Host(`${AGENT_HOSTNAME}`)"
|
|
- "traefik.http.routers.agent.entrypoints=websecure"
|
|
- "traefik.http.routers.agent.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.agent.loadbalancer.server.port=3001"
|
|
|
|
act_runner:
|
|
image: gitea/act_runner:latest
|
|
container_name: act_runner
|
|
restart: unless-stopped
|
|
environment:
|
|
GITEA_INSTANCE_URL: ${GITEA_URL}
|
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${ACT_RUNNER_REGISTRATION_TOKEN}
|
|
GITEA_RUNNER_NAME: gitops-vps-runner
|
|
# catthehacker/ubuntu:act-latest is the standard job-container image for
|
|
# act/act_runner — includes git + docker CLI, which job steps need.
|
|
GITEA_RUNNER_LABELS: docker:docker://catthehacker/ubuntu:act-latest
|
|
# No custom config.yaml: act_runner already auto-detects its own bind-mounted
|
|
# docker.sock and passes it through to job containers with no extra config needed.
|
|
# (A relative-path bind-mounted config file here would also be unreliable under
|
|
# Portainer's git-stack deploy — its cloned checkout doesn't persist for the
|
|
# container's runtime, so the mount source can silently resolve to nothing.)
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- act_runner_data:/data
|
|
networks:
|
|
- web
|
|
|
|
networks:
|
|
web:
|
|
external: true
|
|
|
|
volumes:
|
|
matrix_data:
|
|
agent_workspace:
|
|
act_runner_data:
|