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} # Left disabled: Hermes itself warns that a network-reachable API server combined # with the default unsandboxed ('local') terminal backend gives any caller full # terminal/file access within the container. Matrix is the actual interface in use; # re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if # claude-agent ever needs to call Hermes programmatically. API_SERVER_ENABLED: "false" volumes: - /home/william/hermes-data:/opt/data networks: - web # Without this the image's default command launches the interactive CLI, which # immediately exits ("Input is not a terminal") since a detached container has no # stdin — the container then just sits there having done nothing, every restart. command: ["gateway", "run"] claude-agent: # Gitea PR-review only now — no Matrix presence (see hermes above; only one agent # is meant to be in Matrix). Still triggered by Gitea's pull_request webhook and # posts review comments there, entirely independent of Matrix/LiteLLM. image: ${GITEA_REGISTRY_IMAGE} 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} 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: