Files
gitops-automation/docker-compose.yml
T
william 9fa025f7d5 Add Hermes Agent as a second native Matrix presence
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.
2026-08-23 15:46:58 +00:00

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: