Reviewed-on: #5
gitops-automation
Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See
~/.claude/plans/cozy-honking-lantern.md on the host for the full design rationale.
Three things this gives you:
- PR review: opening/updating a PR in a watched Gitea repo gets a Claude-authored review comment.
- GitOps redeploy: pushing to
mainon this repo rebuilds theclaude-agentimage (Gitea Actions) and redeploys the stack (Portainer webhook). - Chat-driven coding agent:
!claude owner/repo <instruction>in the Matrix control room clones the repo, runs Claude Code, and opens a PR with the result. - Ask other models:
!ai <prompt>(default model) or!ai provider/model <prompt>(e.g.!ai google/gemini-2.0-flash-001 explain this error) queries any model on OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike!claudewhich is the only command that can edit files and open PRs.
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
Prerequisites (one-time, on the VPS)
sudo usermod -aG docker william # then start a new shell/session
docker network create web
Bring-up order
-
DNS — confirm these resolve to
217.160.66.143:gitea.apps.williamturner.eu,portainer.apps.williamturner.eu,matrix.apps.williamturner.eu,agent.apps.williamturner.eu. -
Front Gitea with Traefik: edit
~/gitea/docker-compose.yml— add it to thewebnetwork and Traefik labels (mirror thematrix-homeserverblock in this repo'sdocker-compose.yml, using port3000as the service port and ruleHost(`gitea.apps.williamturner.eu`)). Update~/gitea/data/gitea/conf/app.ini:DOMAINandROOT_URL→https://gitea.apps.williamturner.eu/. Recreate the container, confirm the HTTPS URL works, then remove3000:3000from the port mapping. -
Front Portainer with Traefik: same pattern on
~/portainer-compose.yaml, service port9443(Portainer serves TLS itself on that port — either terminate TLS at Traefik withtraefik.http.services.portainer.loadbalancer.server.scheme=httpsandserversTransportwith insecure skip-verify, or simplest: also expose Portainer's plain HTTP port internally and point Traefik at that instead). Confirmhttps://portainer.apps.williamturner.euworks before removing9443:9443. -
Create the
gitops-automationrepo in Gitea (viahttp://217.160.66.143:3000if step 2 isn't done yet, otherwise the HTTPS URL), push this directory to it. -
Gitea API token: user Settings → Applications → generate a token with repo + webhook scopes. Put it in
.envasGITEA_TOKEN. -
Gitea Actions runner: admin Settings → Actions → Runners → create registration token →
.envasACT_RUNNER_REGISTRATION_TOKEN. Also set repo-level Actions variablesGITEA_HOST(e.g.gitea.apps.williamturner.eu) and secretGITEA_TOKEN(Settings → Actions → Variables/Secrets on the repo) — the workflow in.gitea/workflows/build.ymlreads those. -
First image build (registry is empty until Actions runs once):
cp .env.example .env # fill in values as you go docker build -t "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" ./agent docker login <gitea-host> -u <your-username> docker push "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" -
Bring up the stack, ideally as a Portainer "Repository" stack pointed at this repo (so it's also the GitOps redeploy target) — or directly:
docker compose up -d -
First boot: Matrix bot account — with
MATRIX_ALLOW_REGISTRATION=truein.env, redeploymatrix-homeserver, then register the bot:curl -s https://matrix.apps.williamturner.eu/_matrix/client/v3/register \ -H 'Content-Type: application/json' \ -d '{"username":"claude-bot","password":"<pick one>","auth":{"type":"m.login.dummy"}}'This returns an
access_token— put it in.envasMATRIX_BOT_TOKEN. Then setMATRIX_ALLOW_REGISTRATION=falseand redeploymatrix-homeserveragain. -
Control room: from any Matrix client logged in as yourself on this homeserver, create a room, invite
@claude-bot:matrix.apps.williamturner.eu, copy the room ID into.envasMATRIX_CONTROL_ROOM_ID. Redeployclaude-agent. -
Portainer stack webhook: in the stack's settings, enable the webhook, copy the URL into
.env/repo secrets asPORTAINER_STACK_WEBHOOK_URL. -
Gitea webhooks on each repo you want automation for:
- push →
main→PORTAINER_STACK_WEBHOOK_URL(only needed on this repo, for GitOps redeploy of the automation stack itself) - pull request (opened, synchronized) →
https://agent.apps.williamturner.eu/webhooks/gitea, secret =GITEA_WEBHOOK_SECRET, on every repo you want auto-reviewed.
- push →
-
Firewall:
sudo ufw allow 80/tcp 443/tcp; once the HTTPS routes above are all confirmed working,sudo ufw delete allow 3000/tcpandsudo ufw delete allow 9443/tcp. -
Branch protection on
main, on every repo you want the bot working on (so it's structurally limited to opening PRs, never merging or pushing directly) — via the repo's Settings → Branches → Add Rule, or the API:curl -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ "$GITEA_URL/api/v1/repos/<owner>/<repo>/branch_protections" \ -d '{ "rule_name": "main", "enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": ["<your-human-username>"] }'This blocks all direct pushes to
main(everyone goes through a PR) and restricts merging to the human usernames listed —claude-bot(see below) is never in that list, so it structurally cannot merge, only open PRs, regardless of what its token can do. -
Dedicated bot account (recommended over using your own account/token for the agent): create a separate Gitea user (e.g.
claude-bot) via Site Administration → User Accounts, generate its own token (scopes: repository, issue, package — noadmin,organization, oruserneeded), add it as a repo Collaborator with Write permission on each automated repo, then use its token asGITEA_TOKEN(agent) andREGISTRY_TOKEN(CI secret) instead of your own. This makes every PR comment, branch, and PR clearly attributed to the bot instead of you, and its access is easy to revoke independently.
Smoke test
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
- In the Matrix control room:
!claude owner/repo add a comment to the README→ expect a "working on it" reply, then a PR link. git pushtomainon this repo → expect a Gitea Actions run, then a Portainer redeploy.
Notes
agent/src/runner.jsis the only thing that ever runsgit commit/git push/git checkout— Claude Code itself is explicitly denied those tools (--disallowedTools), so even a misbehaving prompt can't push directly or touchmain.- The Matrix bot only reacts inside
MATRIX_CONTROL_ROOM_ID; keep that room invite-only. .gitea/workflows/build.ymlassumes the act_runner labeldocker— checkGITEA_RUNNER_LABELSindocker-compose.ymlmatches what you actually registered.