Scaffold Matrix + GitOps + Claude automation stack
build-agent / build-and-push (push) Failing after 18s
build-agent / build-and-push (push) Failing after 18s
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Copy to .env and fill in. Never commit the real .env.
|
||||
|
||||
# --- domain / TLS ---
|
||||
ACME_EMAIL=you@example.com
|
||||
MATRIX_SERVER_NAME=matrix.apps.williamturner.eu
|
||||
AGENT_HOSTNAME=agent.apps.williamturner.eu
|
||||
# Set to true ONLY for the first-boot window while creating the bot account,
|
||||
# then back to false (or unset) and redeploy. See README.
|
||||
MATRIX_ALLOW_REGISTRATION=false
|
||||
|
||||
# --- gitea ---
|
||||
GITEA_URL=https://gitea.apps.williamturner.eu
|
||||
GITEA_TOKEN=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
# Image the agent runs from — built and pushed by .gitea/workflows/build.yml
|
||||
GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu/<your-gitea-username>/claude-agent:latest
|
||||
|
||||
# --- anthropic ---
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# --- matrix bot ---
|
||||
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
|
||||
MATRIX_BOT_TOKEN=
|
||||
MATRIX_CONTROL_ROOM_ID=
|
||||
|
||||
# --- portainer (GitOps redeploy) ---
|
||||
PORTAINER_STACK_WEBHOOK_URL=
|
||||
|
||||
# --- gitea actions runner ---
|
||||
ACT_RUNNER_REGISTRATION_TOKEN=
|
||||
@@ -0,0 +1,26 @@
|
||||
name: build-agent
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "agent/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# If your act_runner advertises a different label than "docker" (check its
|
||||
# config.yaml / GITEA_RUNNER_LABELS), update this to match.
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
run: |
|
||||
echo "${{ secrets.GITEA_TOKEN }}" | docker login "${{ vars.GITEA_HOST }}" \
|
||||
-u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Build and push claude-agent image
|
||||
run: |
|
||||
IMAGE="${{ vars.GITEA_HOST }}/${{ gitea.repository }}/claude-agent:latest"
|
||||
docker build -t "$IMAGE" ./agent
|
||||
docker push "$IMAGE"
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
node_modules/
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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 `main` on this repo rebuilds the `claude-agent` image
|
||||
(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.
|
||||
|
||||
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
|
||||
|
||||
## Prerequisites (one-time, on the VPS)
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker william # then start a new shell/session
|
||||
docker network create web
|
||||
```
|
||||
|
||||
## Bring-up order
|
||||
|
||||
1. **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`.
|
||||
|
||||
2. **Front Gitea with Traefik**: edit `~/gitea/docker-compose.yml` — add it to the `web`
|
||||
network and Traefik labels (mirror the `matrix-homeserver` block in this repo's
|
||||
`docker-compose.yml`, using port `3000` as the service port and rule
|
||||
`` Host(`gitea.apps.williamturner.eu`) ``). Update `~/gitea/data/gitea/conf/app.ini`:
|
||||
`DOMAIN` and `ROOT_URL` → `https://gitea.apps.williamturner.eu/`. Recreate the container,
|
||||
confirm the HTTPS URL works, *then* remove `3000:3000` from the port mapping.
|
||||
|
||||
3. **Front Portainer with Traefik**: same pattern on `~/portainer-compose.yaml`, service
|
||||
port `9443` (Portainer serves TLS itself on that port — either terminate TLS at Traefik
|
||||
with `traefik.http.services.portainer.loadbalancer.server.scheme=https` and
|
||||
`serversTransport` with insecure skip-verify, or simplest: also expose Portainer's plain
|
||||
HTTP port internally and point Traefik at that instead). Confirm
|
||||
`https://portainer.apps.williamturner.eu` works before removing `9443:9443`.
|
||||
|
||||
4. **Create the `gitops-automation` repo in Gitea** (via `http://217.160.66.143:3000` if
|
||||
step 2 isn't done yet, otherwise the HTTPS URL), push this directory to it.
|
||||
|
||||
5. **Gitea API token**: user Settings → Applications → generate a token with repo +
|
||||
webhook scopes. Put it in `.env` as `GITEA_TOKEN`.
|
||||
|
||||
6. **Gitea Actions runner**: admin Settings → Actions → Runners → create registration
|
||||
token → `.env` as `ACT_RUNNER_REGISTRATION_TOKEN`. Also set repo-level Actions
|
||||
variables `GITEA_HOST` (e.g. `gitea.apps.williamturner.eu`) and secret `GITEA_TOKEN`
|
||||
(Settings → Actions → Variables/Secrets on the repo) — the workflow in
|
||||
`.gitea/workflows/build.yml` reads those.
|
||||
|
||||
7. **First image build** (registry is empty until Actions runs once):
|
||||
```bash
|
||||
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)"
|
||||
```
|
||||
|
||||
8. **Bring up the stack**, ideally as a Portainer "Repository" stack pointed at this repo
|
||||
(so it's also the GitOps redeploy target) — or directly:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
9. **First boot: Matrix bot account** — with `MATRIX_ALLOW_REGISTRATION=true` in `.env`,
|
||||
redeploy `matrix-homeserver`, then register the bot:
|
||||
```bash
|
||||
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 `.env` as `MATRIX_BOT_TOKEN`. Then set
|
||||
`MATRIX_ALLOW_REGISTRATION=false` and redeploy `matrix-homeserver` again.
|
||||
|
||||
10. **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 `.env` as `MATRIX_CONTROL_ROOM_ID`. Redeploy `claude-agent`.
|
||||
|
||||
11. **Portainer stack webhook**: in the stack's settings, enable the webhook, copy the URL
|
||||
into `.env`/repo secrets as `PORTAINER_STACK_WEBHOOK_URL`.
|
||||
|
||||
12. **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.
|
||||
|
||||
13. **Firewall**: `sudo ufw allow 80/tcp 443/tcp`; once the HTTPS routes above are all
|
||||
confirmed working, `sudo ufw delete allow 3000/tcp` and `sudo ufw delete allow 9443/tcp`.
|
||||
|
||||
## 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 push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy.
|
||||
|
||||
## Notes
|
||||
|
||||
- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git
|
||||
checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`),
|
||||
so even a misbehaving prompt can't push directly or touch `main`.
|
||||
- The Matrix bot only reacts inside `MATRIX_CONTROL_ROOM_ID`; keep that room invite-only.
|
||||
- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check
|
||||
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
openssh-client \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY src ./src
|
||||
|
||||
RUN git config --global user.email "claude-agent@apps.williamturner.eu" \
|
||||
&& git config --global user.name "claude-agent" \
|
||||
&& git config --global --add safe.directory '*'
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3001
|
||||
CMD ["node", "src/server.js"]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "claude-gitops-agent",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"matrix-bot-sdk": "^0.7.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const GITEA_URL = process.env.GITEA_URL;
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
Authorization: `token ${GITEA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function assertOk(res, action) {
|
||||
if (!res.ok) {
|
||||
throw new Error(`${action} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postPRComment(owner, repo, index, body) {
|
||||
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/issues/${index}/comments`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
await assertOk(res, "post PR comment");
|
||||
}
|
||||
|
||||
export async function createBranch(owner, repo, newBranch, oldBranch = "main") {
|
||||
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }),
|
||||
});
|
||||
await assertOk(res, "create branch");
|
||||
}
|
||||
|
||||
export async function createPullRequest(owner, repo, { head, base = "main", title, body }) {
|
||||
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ head, base, title, body }),
|
||||
});
|
||||
await assertOk(res, "create PR");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys.
|
||||
export function authenticatedCloneUrl(cloneUrl) {
|
||||
const u = new URL(cloneUrl);
|
||||
u.username = "claude-agent";
|
||||
u.password = GITEA_TOKEN;
|
||||
return u.toString();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
|
||||
import { runChatTask } from "./runner.js";
|
||||
|
||||
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
|
||||
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
|
||||
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
|
||||
const GITEA_URL = process.env.GITEA_URL;
|
||||
|
||||
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
|
||||
function parseCommand(text) {
|
||||
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
|
||||
if (!match) return null;
|
||||
const [, repoFullName, instruction] = match;
|
||||
return { repoFullName, instruction: instruction.trim() };
|
||||
}
|
||||
|
||||
export async function startMatrixBot() {
|
||||
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
|
||||
console.warn("Matrix env vars not set — skipping bot startup");
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
|
||||
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
|
||||
AutojoinRoomsMixin.setupOnClient(client);
|
||||
|
||||
const selfUserId = await client.getUserId();
|
||||
|
||||
client.on("room.message", async (roomId, event) => {
|
||||
// Invite-only control room enforces who can reach the bot at all; this just
|
||||
// scopes command handling to that one room and ignores the bot's own messages.
|
||||
if (roomId !== CONTROL_ROOM_ID) return;
|
||||
if (event.sender === selfUserId) return;
|
||||
const body = event.content?.body;
|
||||
if (!body) return;
|
||||
|
||||
const cmd = parseCommand(body);
|
||||
if (!cmd) return;
|
||||
|
||||
const [owner, repo] = cmd.repoFullName.split("/");
|
||||
await client.sendText(roomId, `Working on it: ${cmd.repoFullName} — ${cmd.instruction}`);
|
||||
|
||||
try {
|
||||
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
|
||||
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction });
|
||||
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
|
||||
} catch (err) {
|
||||
console.error("chat task failed", err);
|
||||
await client.sendText(roomId, `Failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
await client.start();
|
||||
console.log("Matrix bot started, room:", CONTROL_ROOM_ID);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const WORKSPACE_ROOT = "/workspace";
|
||||
|
||||
async function run(cmd, args, opts = {}) {
|
||||
const { stdout } = await execFileAsync(cmd, args, {
|
||||
maxBuffer: 1024 * 1024 * 32,
|
||||
...opts,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function withWorkspace(fn) {
|
||||
await mkdir(WORKSPACE_ROOT, { recursive: true });
|
||||
const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "job-"));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
|
||||
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
|
||||
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
|
||||
// Claude can never push/commit/checkout itself — this script owns those steps.
|
||||
async function runClaude(cwd, prompt, { allowEdits }) {
|
||||
const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
|
||||
if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
|
||||
|
||||
const args = [
|
||||
"-p", prompt,
|
||||
"--output-format", "text",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--disallowedTools", disallowed.join(","),
|
||||
];
|
||||
|
||||
return run("claude", args, { cwd });
|
||||
}
|
||||
|
||||
export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, prBody }) {
|
||||
return withWorkspace(async (dir) => {
|
||||
const authedUrl = authenticatedCloneUrl(cloneUrl);
|
||||
await run("git", ["clone", "--quiet", authedUrl, dir]);
|
||||
await run("git", ["fetch", "--quiet", "origin", ref], { cwd: dir });
|
||||
await run("git", ["checkout", "--quiet", ref], { cwd: dir });
|
||||
|
||||
const prompt = [
|
||||
"You are reviewing a pull request. Diff HEAD against the base branch (origin/main)",
|
||||
"and give a concise, specific code review: correctness bugs first, then",
|
||||
"simplification/reuse/efficiency notes. Read-only — do not edit any files.",
|
||||
`PR title: ${prTitle}`,
|
||||
`PR description:\n${prBody}`,
|
||||
].join("\n");
|
||||
|
||||
return runClaude(dir, prompt, { allowEdits: false });
|
||||
});
|
||||
}
|
||||
|
||||
export async function runChatTask({ owner, repo, cloneUrl, instruction }) {
|
||||
return withWorkspace(async (dir) => {
|
||||
const authedUrl = authenticatedCloneUrl(cloneUrl);
|
||||
await run("git", ["clone", "--quiet", authedUrl, dir]);
|
||||
|
||||
const branch = `claude/${crypto.randomBytes(4).toString("hex")}`;
|
||||
await createBranch(owner, repo, branch);
|
||||
await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir });
|
||||
await run("git", ["checkout", "--quiet", branch], { cwd: dir });
|
||||
|
||||
const prompt = [
|
||||
"Implement the change described below in this repository. Make the smallest",
|
||||
"correct change that satisfies it. Do not run git commit, git push, or git",
|
||||
"checkout yourself — just edit files; committing and pushing happens separately.",
|
||||
`Instruction: ${instruction}`,
|
||||
].join("\n");
|
||||
|
||||
await runClaude(dir, prompt, { allowEdits: true });
|
||||
|
||||
await run("git", ["add", "-A"], { cwd: dir });
|
||||
const status = await run("git", ["status", "--porcelain"], { cwd: dir });
|
||||
if (!status.trim()) {
|
||||
throw new Error("Claude made no changes for this instruction");
|
||||
}
|
||||
await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir });
|
||||
await run("git", ["push", "--quiet", "origin", branch], { cwd: dir });
|
||||
|
||||
return createPullRequest(owner, repo, {
|
||||
head: branch,
|
||||
title: `claude: ${instruction}`.slice(0, 200),
|
||||
body: `Requested via Matrix:\n\n> ${instruction}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import express from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { postPRComment } from "./gitea.js";
|
||||
import { reviewPullRequest } from "./runner.js";
|
||||
import { startMatrixBot } from "./matrixBot.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
express.json({
|
||||
verify: (req, _res, buf) => {
|
||||
req.rawBody = buf;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET;
|
||||
|
||||
function verifySignature(req) {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const sig = req.get("X-Gitea-Signature");
|
||||
if (!sig) return false;
|
||||
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex");
|
||||
const sigBuf = Buffer.from(sig, "hex");
|
||||
const expBuf = Buffer.from(expected, "hex");
|
||||
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
|
||||
}
|
||||
|
||||
app.get("/healthz", (_req, res) => res.send("ok"));
|
||||
|
||||
app.post("/webhooks/gitea", async (req, res) => {
|
||||
if (!verifySignature(req)) {
|
||||
return res.status(401).send("bad signature");
|
||||
}
|
||||
// Ack immediately — Gitea has a short webhook timeout and the review itself takes a while.
|
||||
res.status(202).send("accepted");
|
||||
|
||||
const event = req.get("X-Gitea-Event");
|
||||
const body = req.body;
|
||||
|
||||
try {
|
||||
if (event === "pull_request" && ["opened", "synchronize"].includes(body.action)) {
|
||||
const { repository, pull_request: pr } = body;
|
||||
const [owner, repo] = repository.full_name.split("/");
|
||||
const review = await reviewPullRequest({
|
||||
owner,
|
||||
repo,
|
||||
ref: pr.head.sha,
|
||||
cloneUrl: repository.clone_url,
|
||||
prTitle: pr.title,
|
||||
prBody: pr.body || "",
|
||||
});
|
||||
await postPRComment(owner, repo, pr.number, review);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("webhook handling failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`claude-agent listening on :${PORT}`);
|
||||
});
|
||||
|
||||
startMatrixBot().catch((err) => {
|
||||
console.error("matrix bot failed to start:", err);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=web"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
- "--log.level=INFO"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "traefik_letsencrypt:/letsencrypt"
|
||||
networks:
|
||||
- web
|
||||
|
||||
# Routes for services that live in OTHER compose files (Gitea, Portainer) are added
|
||||
# as labels on those containers directly, not here — see README "Fronting existing
|
||||
# services" section.
|
||||
|
||||
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}
|
||||
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"
|
||||
|
||||
claude-agent:
|
||||
image: ${GITEA_REGISTRY_IMAGE}
|
||||
container_name: claude-agent
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
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
|
||||
GITEA_RUNNER_LABELS: docker:docker://node:22-bookworm
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- act_runner_data:/data
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
matrix_data:
|
||||
agent_workspace:
|
||||
act_runner_data:
|
||||
Reference in New Issue
Block a user