6 Commits

Author SHA1 Message Date
ml 8adbd3fac4 Add Clay agent interface, fix registry UI routing, and misc improvements
- Deploy Clay (claude Code web UI) at agent.ladkau.de behind basic auth;
  Traefik proxies to clay's HTTPS port 2633 with insecureSkipVerify
- Document MCP server persistence: binaries need to be baked into the
  Dockerfile, config in /opt/clay/data survives rebuilds
- Document scheduled agent workflows via Gitea Actions on.schedule with
  email reporting via Gmail SMTP
- Fix registry UI: split /v2/ (registry) and / (UI) into separate Traefik
  routers; add registry_internal network
- Add weekly registry GC cron job (/usr/local/bin/registry-gc)
- Remove rate-limit middleware from Gitea router (act_runner polling
  exceeded 60 req/min limit)
- Set Traefik websecure readTimeout: 0 to fix large layer upload 499s
2026-08-01 07:55:20 +02:00
ml 735563fc31 Add registry-ui, automated GC, and Gitea runner/rate-limit fixes
- Deploy joxit/docker-registry-ui at cr.ladkau.de/ (Traefik routes
  /v2/ to registry, everything else to UI; REGISTRY_SECURED=true)
- Add weekly cron job (Sunday 03:00) to delete stale uploads, run
  registry garbage-collect, and prune dangling host images
- Remove rate-limit@docker middleware from Gitea router (act_runner
  polling at 2s intervals exceeded the 60 req/min limit)
- Set Traefik websecure readTimeout: 0 to fix large layer upload 499s
- Remove registry rate-limit middleware (was blocking concurrent pushes)
2026-07-26 10:52:26 +02:00
ml 22225a304f Adding Joxit/docker-registry-ui 2026-07-19 10:05:49 +02:00
ml cc01a61dc3 Gitea actions should force pull the build image for every build. 2026-07-04 05:45:17 +02:00
ml 883893f755 Removing rate-limiting middleware from Gitea 2026-07-02 14:19:12 +02:00
ml c968995481 Adding gitea act_runner roles 2026-07-02 09:02:50 +02:00
21 changed files with 631 additions and 22 deletions
+22
View File
@@ -75,6 +75,28 @@ The mail role deploys four containers on `mail_internal`: `mail-db` (PostgreSQL
- Fetchmail 6.6.x rcfile syntax: `ssl` is a user-level option (inline after `password`), port is `smtphost dovecot/24` (slash-separated), `smtpport` keyword does not exist - Fetchmail 6.6.x rcfile syntax: `ssl` is a user-level option (inline after `password`), port is `smtphost dovecot/24` (slash-separated), `smtpport` keyword does not exist
- `mail.ladkau.de` is the Roundcube webmail client, not an MTA — no outgoing SMTP server is configured - `mail.ladkau.de` is the Roundcube webmail client, not an MTA — no outgoing SMTP server is configured
### Clay (Claude Code web interface)
Clay (`agent.ladkau.de`) provides a browser-based UI for Claude Code. The role
builds a Docker image from `ansible/roles/clay/files/Dockerfile` (Node 20 +
`@anthropic-ai/claude-code` + `clay-server`) at deploy time. Key details:
- `ANTHROPIC_API_KEY` is passed as an environment variable from the vault
- Sessions and config are persisted at `/opt/clay/data``/root/.clay` in container
- Projects (git repos) are mounted from `/opt/clay/workspace``/workspace`
- Traefik basic auth (`clay-auth` middleware) uses `clay_users` from the vault
- Scheduled agent workflows use Gitea Actions `on.schedule` + the existing act_runners
### Gitea Actions runners
The `act_runner` role deploys three `gitea/act_runner` containers (Docker executor). Each runner handles one concurrent job. Key details:
- Runners connect to Gitea via the public HTTPS URL — no internal Docker network needed
- Each runner gets its own data dir (`/opt/act_runner/runner-N/`) for its `.runner` registration file
- All runners share a single `config.yml` at `/opt/act_runner/config.yml`
- `/var/run/docker.sock` is mounted — job containers are spawned directly on the host
- `gitea_runner_registration_token` is obtained from Gitea admin UI **after** Gitea is running; add it to the vault and then deploy with `--tags act_runner`
### SSO ### SSO
Keycloak at `sso.ladkau.de` is the identity provider. Gitea and Nextcloud are configured post-provisioning to use it (see `docs/runbook-configuration.md`). Keycloak at `sso.ladkau.de` is the identity provider. Gitea and Nextcloud are configured post-provisioning to use it (see `docs/runbook-configuration.md`).
+19 -1
View File
@@ -89,8 +89,16 @@ roundcube_smtp_port: "587"
# ssl: true # default: true # ssl: true # default: true
# keep: true # default: true — set false to delete from source after fetch # keep: true # default: true — set false to delete from source after fetch
# Container registry (Docker Registry v2) # Gitea Actions runners (act_runner with Docker executor)
act_runner_version: "latest"
act_runner_data_dir: /opt/act_runner
act_runner_count: 3
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# gitea_runner_registration_token: "" # Gitea admin → Site Administration → Runners → Create runner token
# Container registry (Docker Registry v2 + web UI)
registry_data_dir: /opt/registry registry_data_dir: /opt/registry
registry_ui_version: "main"
# Registry users — plaintext passwords, Ansible generates bcrypt hashes at deploy time # Registry users — plaintext passwords, Ansible generates bcrypt hashes at deploy time
# registry_users: # registry_users:
# - username: alice # - username: alice
@@ -116,3 +124,13 @@ dl_sftp_port: 2223
# Status dashboard (public — cloud.ladkau.de root) # Status dashboard (public — cloud.ladkau.de root)
dashboard_data_dir: /opt/dashboard dashboard_data_dir: /opt/dashboard
# Clay — Claude Code web interface (agent.ladkau.de)
domain_clay: "agent.{{ domain_base }}"
clay_data_dir: /opt/clay
clay_users: [] # override in vault with [{username: ..., password: ...}]
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# anthropic_api_key: "" # from console.anthropic.com → API keys
# clay_users:
# - username: admin
# password: "your-password"
@@ -0,0 +1,7 @@
---
- name: Restart act_runner
community.docker.docker_compose_v2:
project_src: "{{ act_runner_data_dir }}"
state: present
pull: missing
recreate: always
+46
View File
@@ -0,0 +1,46 @@
---
- name: Create act_runner base directory
ansible.builtin.file:
path: "{{ act_runner_data_dir }}"
state: directory
owner: root
group: root
mode: "0755"
tags: act_runner
- name: Create per-runner data directories
ansible.builtin.file:
path: "{{ act_runner_data_dir }}/runner-{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop: "{{ range(1, act_runner_count + 1) | list }}"
tags: act_runner
- name: Deploy act_runner config
ansible.builtin.template:
src: config.yml.j2
dest: "{{ act_runner_data_dir }}/config.yml"
owner: root
group: root
mode: "0644"
notify: Restart act_runner
tags: act_runner
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ act_runner_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart act_runner
tags: act_runner
- name: Start act_runner
community.docker.docker_compose_v2:
project_src: "{{ act_runner_data_dir }}"
state: present
pull: missing
tags: act_runner
@@ -0,0 +1,23 @@
# Managed by Ansible — do not edit manually
log:
level: info
runner:
file: .runner # stored in each runner's /data volume
capacity: 1 # one concurrent job per runner container
timeout: 3h
fetch_timeout: 5s
fetch_interval: 2s
cache:
enabled: false # disable built-in cache server; use actions/cache if needed
container:
network: bridge # job containers get default bridge network with internet access
privileged: false
valid_volumes:
- /tmp
force_pull: true # otherwise a job container image update (e.g. a build-image
# rebuild pushed to :latest) is silently ignored — the runner
# just reuses whatever it already has cached under that tag
force_rebuild: false
@@ -0,0 +1,17 @@
# Managed by Ansible — do not edit manually
services:
{% for i in range(1, act_runner_count + 1) %}
runner-{{ i }}:
image: gitea/act_runner:{{ act_runner_version }}
container_name: act-runner-{{ i }}
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=https://{{ domain_gitea }}
- GITEA_RUNNER_REGISTRATION_TOKEN={{ gitea_runner_registration_token }}
- GITEA_RUNNER_NAME=runner-{{ i }}
- CONFIG_FILE=/config.yml
volumes:
- {{ act_runner_data_dir }}/runner-{{ i }}:/data
- {{ act_runner_data_dir }}/config.yml:/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock
{% endfor %}
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine
# Claude Code needs git and bash to operate on repositories
RUN apk add --no-cache git bash curl
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Install clay web server
RUN npm install -g clay-server
# MCP servers and skills — add packages here to survive container rebuilds
# RUN npm install -g @clay-ai/clay-ralph
# RUN npm install -g @modelcontextprotocol/server-filesystem
RUN mkdir -p /workspace /root/.clay
WORKDIR /workspace
EXPOSE 2633
+16
View File
@@ -0,0 +1,16 @@
---
- name: Rebuild and restart clay
community.docker.docker_image:
name: clay:local
build:
path: "{{ clay_data_dir }}"
source: build
force_source: true
state: present
notify: Restart clay
- name: Restart clay
community.docker.docker_compose_v2:
project_src: "{{ clay_data_dir }}"
state: present
recreate: always
+48
View File
@@ -0,0 +1,48 @@
---
- name: Create clay directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ clay_data_dir }}"
- "{{ clay_data_dir }}/data"
- "{{ clay_data_dir }}/workspace"
tags: clay
- name: Deploy Dockerfile
ansible.builtin.copy:
src: Dockerfile
dest: "{{ clay_data_dir }}/Dockerfile"
owner: root
group: root
mode: "0644"
notify: Rebuild and restart clay
tags: clay
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ clay_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart clay
tags: clay
- name: Build clay image
community.docker.docker_image:
name: clay:local
build:
path: "{{ clay_data_dir }}"
source: build
state: present
tags: clay
- name: Start clay
community.docker.docker_compose_v2:
project_src: "{{ clay_data_dir }}"
state: present
tags: clay
@@ -0,0 +1,35 @@
# Managed by Ansible — do not edit manually
services:
clay:
image: clay:local
build:
context: {{ clay_data_dir }}
dockerfile: Dockerfile
container_name: clay
restart: unless-stopped
tty: true
environment:
- ANTHROPIC_API_KEY={{ anthropic_api_key }}
volumes:
- {{ clay_data_dir }}/data:/root/.clay
- {{ clay_data_dir }}/workspace:/workspace
command: ["clay-server", "--yes", "-p", "2633"]
networks:
- traefik_public
labels:
- "traefik.enable=true"
- "traefik.http.routers.clay.rule=Host(`{{ domain_clay }}`)"
- "traefik.http.routers.clay.entrypoints=websecure"
- "traefik.http.routers.clay.tls.certresolver=letsencrypt"
- "traefik.http.services.clay.loadbalancer.server.scheme=https"
- "traefik.http.services.clay.loadbalancer.server.port=2633"
- "traefik.http.routers.clay.middlewares=clay-auth"
{% set ns = namespace(entries=[]) %}
{% for user in clay_users %}
{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %}
{% endfor %}
- "traefik.http.middlewares.clay-auth.basicauth.users={{ ns.entries | join(',') }}"
networks:
traefik_public:
external: true
@@ -25,6 +25,7 @@ SERVICES = [
("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"), ("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"),
("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"), ("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"),
("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"), ("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"),
("Clay", "https://{{ domain_clay }}", 401, "https://{{ domain_clay }}"),
] ]
{% raw %} {% raw %}
@@ -41,7 +41,6 @@ services:
- "traefik.http.routers.gitea.entrypoints=websecure" - "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt" - "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000" - "traefik.http.services.gitea.loadbalancer.server.port=3000"
- "traefik.http.routers.gitea.middlewares=rate-limit@docker"
depends_on: depends_on:
gitea-db: gitea-db:
condition: service_healthy condition: service_healthy
+20
View File
@@ -38,3 +38,23 @@
state: present state: present
pull: missing pull: missing
tags: registry tags: registry
- name: Deploy registry GC script
ansible.builtin.template:
src: registry-gc.sh.j2
dest: /usr/local/bin/registry-gc
owner: root
group: root
mode: "0750"
tags: registry
- name: Schedule weekly registry GC cron job
ansible.builtin.cron:
name: registry-gc
user: root
weekday: "0"
hour: "3"
minute: "0"
job: /usr/local/bin/registry-gc >> /var/log/registry-gc.log 2>&1
state: present
tags: registry
@@ -14,16 +14,40 @@ services:
- {{ registry_data_dir }}/auth:/auth:ro - {{ registry_data_dir }}/auth:/auth:ro
networks: networks:
- traefik_public - traefik_public
- registry_internal
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.registry.rule=Host(`{{ domain_registry }}`)" # PathPrefix(/v2) is more specific than the UI's bare Host rule — Traefik routes docker CLI traffic here
- "traefik.http.routers.registry.entrypoints=websecure" - "traefik.http.routers.registry-api.rule=Host(`{{ domain_registry }}`) && PathPrefix(`/v2`)"
- "traefik.http.routers.registry.tls.certresolver=letsencrypt" - "traefik.http.routers.registry-api.entrypoints=websecure"
- "traefik.http.routers.registry-api.tls.certresolver=letsencrypt"
- "traefik.http.services.registry.loadbalancer.server.port=5000" - "traefik.http.services.registry.loadbalancer.server.port=5000"
- "traefik.http.routers.registry.middlewares=registry-buffering,rate-limit@docker"
# Remove body size limit so large image layers can be pushed registry-ui:
- "traefik.http.middlewares.registry-buffering.buffering.maxRequestBodyBytes=0" image: joxit/docker-registry-ui:{{ registry_ui_version }}
container_name: registry-ui
restart: unless-stopped
environment:
- SINGLE_REGISTRY=true
- REGISTRY_SECURED=true
- REGISTRY_URL=https://{{ domain_registry }}
- DELETE_IMAGES=true
- SHOW_CATALOG_NB_TAGS=true
- SHOW_CONTENT_DIGEST=true
- CATALOG_ELEMENTS_LIMIT=1000
- TAGLIST_PAGE_SIZE=100
- REGISTRY_TITLE=Container Registry
networks:
- traefik_public
labels:
- "traefik.enable=true"
- "traefik.http.routers.registry-ui.rule=Host(`{{ domain_registry }}`)"
- "traefik.http.routers.registry-ui.entrypoints=websecure"
- "traefik.http.routers.registry-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.registry-ui.loadbalancer.server.port=80"
networks: networks:
traefik_public: traefik_public:
external: true external: true
registry_internal:
internal: true
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Managed by Ansible — do not edit manually
# Weekly registry maintenance: remove stale uploads, run GC, prune dangling host images.
set -euo pipefail
LOG_TAG="registry-gc"
REGISTRY_COMPOSE_DIR="{{ registry_data_dir }}"
REGISTRY_DATA_DIR="{{ registry_data_dir }}/data"
log() { logger -t "$LOG_TAG" -- "$*"; echo "$(date -Iseconds) $*"; }
log "=== Registry cleanup started ==="
# 1. Remove stale upload sessions (failed or abandoned pushes)
find "{{ registry_data_dir }}/docker/registry/v2/repositories" \
-type d -name '_uploads' -exec rm -rf {} + 2>/dev/null || true
log "Stale _uploads removed"
# 2. Garbage-collect unreferenced blobs (stop → collect → start)
cd "$REGISTRY_COMPOSE_DIR"
docker compose stop registry
docker run --rm \
-v "${REGISTRY_DATA_DIR}:/var/lib/registry" \
registry:2 garbage-collect /etc/docker/registry/config.yml
docker compose start registry
log "Registry GC complete, registry restarted"
# 3. Remove dangling Docker images from the host
PRUNED=$(docker image prune -f)
RECLAIMED=$(echo "$PRUNED" | awk '/Total reclaimed/{print $NF}')
log "Docker image prune complete: ${RECLAIMED:-0B} reclaimed"
log "=== Registry cleanup done ==="
@@ -24,9 +24,15 @@ entryPoints:
permanent: true permanent: true
websecure: websecure:
address: ":443" address: ":443"
transport:
respondingTimeouts:
readTimeout: 0 # no limit — required for large registry layer uploads
imaps: imaps:
address: ":993" address: ":993"
serversTransport:
insecureSkipVerify: true # allows Traefik to proxy clay's self-signed d.clay.studio cert on port 2633
providers: providers:
docker: docker:
exposedByDefault: false exposedByDefault: false
+2
View File
@@ -7,6 +7,7 @@
- docker - docker
- traefik - traefik
- gitea - gitea
- act_runner
- nextcloud - nextcloud
- sso - sso
- mail - mail
@@ -14,4 +15,5 @@
- k8s - k8s
- vaultwarden - vaultwarden
- dl - dl
- clay
- dashboard - dashboard
+248
View File
@@ -25,6 +25,7 @@ The script checks these endpoints and verifies the expected HTTP status code:
| `https://k8s.ladkau.de` | 200 | Placeholder page | | `https://k8s.ladkau.de` | 200 | Placeholder page |
| `https://vault.ladkau.de` | 200 | Vaultwarden web vault | | `https://vault.ladkau.de` | 200 | Vaultwarden web vault |
| `https://dl.ladkau.de` | 200 | Public download server | | `https://dl.ladkau.de` | 200 | Public download server |
| `https://agent.ladkau.de` | 401 | Clay — basic-auth prompt, correct without credentials |
A `000` result means the connection was refused or timed out — a container that A `000` result means the connection was refused or timed out — a container that
did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on
@@ -209,6 +210,55 @@ With the alias in place you can use the shorter SCP-like URL:
git clone git@gitea.ladkau.de:<username>/myrepo.git git clone git@gitea.ladkau.de:<username>/myrepo.git
``` ```
### 3.4 Deploy Gitea Actions runners
Three `act_runner` containers (Docker executor) are provisioned by the
`act_runner` Ansible role. Each runner handles one concurrent job; together
they allow up to three parallel workflow jobs.
**This step requires Gitea to be running and the admin account to exist
(step 3.1). Runners cannot register until a token is obtained from Gitea.**
**Step 1 — Get a runner registration token**
1. Go to `https://gitea.ladkau.de` → sign in as admin.
2. Navigate to **Site Administration** (top-right menu) →
**Actions** → **Runners**.
3. Click **Create runner token** and copy the token.
**Step 2 — Add the token to the vault**
```bash
ansible-vault edit ansible/group_vars/all/vault.yml
```
Add the key:
```yaml
gitea_runner_registration_token: "<token-from-step-1>"
```
**Step 3 — Deploy the runners**
```bash
ansible-playbook -i ansible/inventory.ini ansible/site.yml \
--tags act_runner --ask-vault-pass
```
Ansible creates `/opt/act_runner/` with a shared `config.yml` and three
per-runner data directories (`runner-1/`, `runner-2/`, `runner-3/`). On first
start each container auto-registers with Gitea and writes a `.runner` file to
its data directory. Subsequent restarts reuse the saved registration.
**Verify**
Back in Gitea **Site Administration → Actions → Runners**, all three runners
should appear as **Online** within a few seconds.
> **Security note:** Each runner container mounts `/var/run/docker.sock`.
> This gives workflow jobs root-equivalent access to the host Docker daemon.
> Only run workflows from trusted repositories.
## 4. Nextcloud ## 4. Nextcloud
The admin credentials are set via `nextcloud_admin_user` and The admin credentials are set via `nextcloud_admin_user` and
@@ -362,6 +412,8 @@ The file is then available at:
## 8. Container registry ## 8. Container registry
### 8.1 Docker CLI usage
```bash ```bash
# Login # Login
docker login cr.ladkau.de docker login cr.ladkau.de
@@ -382,3 +434,199 @@ user, edit the vault and redeploy:
ansible-vault edit ansible/group_vars/all/vault.yml ansible-vault edit ansible/group_vars/all/vault.yml
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags registry --ask-vault-pass ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags registry --ask-vault-pass
``` ```
### 8.2 Automated garbage collection
A cleanup script runs every **Sunday at 03:00** via cron (deployed by the
`registry` Ansible role). Each run:
1. Deletes stale `_uploads/` sessions (failed or abandoned pushes)
2. Stops the registry, runs `garbage-collect` to remove unreferenced blobs,
then restarts it
3. Prunes dangling Docker images from the host
Output is appended to `/var/log/registry-gc.log` and tagged `registry-gc` in
syslog. To check recent runs:
```bash
tail -50 /var/log/registry-gc.log
# or
grep registry-gc /var/log/syslog
```
To run manually at any time:
```bash
sudo /usr/local/bin/registry-gc
```
### 8.3 Registry web UI
A web dashboard is available at `https://cr.ladkau.de/`. Sign in with any
`registry_users` credential from the vault. The UI allows browsing repositories
and tags, inspecting image manifests and digests, and deleting images.
Traefik routes `/v2/` (Docker API) to the registry container and everything else
to the UI container — both on the same domain, so no CORS configuration is
needed. `docker push` and `docker pull` work exactly as before.
## 9. Clay — Claude Code web interface
Clay is a browser-based front end for Claude Code, served at
`https://agent.ladkau.de`. Access is protected by HTTP basic auth using the
`clay_users` credentials from the vault.
### 9.1 Add projects
The container mounts `/opt/clay/workspace` at `/workspace`. Clone Gitea
repositories there to make them available to Claude Code:
```bash
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148
cd /opt/clay/workspace
git clone https://gitea.ladkau.de/<username>/<repo>.git
```
Then in the clay web UI, use **Add project** and point it at `/workspace/<repo>`.
### 9.2 MCP servers and skills
Clay configuration (MCP server entries, session history, project registrations)
is stored in `~/.clay`, which is mounted from `/opt/clay/data` on the host and
survives container rebuilds.
MCP server **binaries** installed interactively inside a running container (e.g.
via `npm install -g` in a clay terminal) are part of the container filesystem
and are lost when the image is rebuilt.
To make a package survive rebuilds, add it to
`ansible/roles/clay/files/Dockerfile` and redeploy:
```dockerfile
# MCP servers and skills — add packages here to survive container rebuilds
RUN npm install -g @clay-ai/clay-ralph
RUN npm install -g @modelcontextprotocol/server-filesystem
```
```bash
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass
```
Ansible detects the Dockerfile change and triggers a rebuild automatically.
### 9.3 Rebuild after clay updates
The Docker image is built from `ansible/roles/clay/files/Dockerfile` at deploy
time. To pull upstream changes to `clay-server` or `@anthropic-ai/claude-code`,
rebuild the image and restart the container:
```bash
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass
```
The Ansible role forces a rebuild whenever the Dockerfile changes; to force a
rebuild without a Dockerfile change, delete the cached image first:
```bash
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \
"sudo docker image rm clay:local && sudo docker compose -f /opt/clay/docker-compose.yml up -d"
```
### 9.4 Interactive sessions vs. scheduled runs
Clay and Gitea Actions serve different purposes and are independent of each
other:
| | Clay | Gitea Actions |
|---|---|---|
| **Trigger** | You, in the browser | Schedule or manual button click |
| **Use case** | Exploratory, iterative work | Recurring automated tasks |
| **Project** | Repos cloned into `/opt/clay/workspace/` | Checked out fresh per run |
| **Output** | Interactive UI | Logs + optional email |
Clay has no scheduling API — there is no way to trigger a Clay session
programmatically. For recurring agent tasks, Gitea Actions runs Claude Code
directly on the act_runners, independent of Clay. If both operate on the same
git repository the context is identical, but the sessions are separate.
### 9.5 Scheduled Claude Code runs via Gitea Actions
The act_runner containers can run Claude Code on a recurring schedule using
Gitea's `on.schedule` trigger. Adding `workflow_dispatch` alongside it allows
the same workflow to be triggered manually from the Gitea UI without waiting
for the next scheduled run.
**Set up secrets**
In the repository go to **Settings → Secrets → Actions** (or
**Site Administration → Actions → Secrets** for org-wide secrets) and add:
| Secret | Value |
|--------|-------|
| `ANTHROPIC_API_KEY` | API key from the vault |
| `SMTP_USERNAME` | Gmail address used as the SMTP sender |
| `SMTP_PASSWORD` | Gmail App Password — myaccount.google.com → Security → App passwords |
**Workflow file**
Add `.gitea/workflows/weekly-agent.yml` to the repository:
```yaml
name: Weekly agent run
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
workflow_dispatch: # also runnable manually from the Gitea Actions UI
jobs:
agent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Run agent
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
claude --print "Your prompt here" \
--output-format text \
| tee agent-output.txt
- name: Send report email
env:
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
run: |
python3 - <<'EOF'
import smtplib, os
from email.mime.text import MIMEText
with open("agent-output.txt") as f:
body = f.read()
msg = MIMEText(body)
msg["Subject"] = "Weekly agent report"
msg["From"] = os.environ["SMTP_USERNAME"]
msg["To"] = os.environ["SMTP_USERNAME"]
with smtplib.SMTP("smtp.gmail.com", 587) as s:
s.starttls()
s.login(os.environ["SMTP_USERNAME"], os.environ["SMTP_PASSWORD"])
s.send_message(msg)
EOF
```
Adjust the `cron` expression, prompt, and `msg["To"]` address as needed.
**Run manually**
Go to **Repository → Actions**, select **Weekly agent run** from the workflow
list, and click **Run workflow**. The run appears immediately in the Actions log.
**Verify the schedule is active**
After pushing the workflow file, scheduled runs appear automatically in
**Repository → Actions** at the configured time.
+35 -14
View File
@@ -81,6 +81,7 @@ Let's Encrypt certificates on first start and DNS must resolve at that point.
| k8s.ladkau.de | A → server IP | | k8s.ladkau.de | A → server IP |
| vault.ladkau.de | A → server IP | | vault.ladkau.de | A → server IP |
| dl.ladkau.de | A → server IP | | dl.ladkau.de | A → server IP |
| agent.ladkau.de | A → server IP |
### 4. Create the vault and populate secrets ### 4. Create the vault and populate secrets
@@ -121,6 +122,17 @@ registry_users:
- username: alice - username: alice
password: "your-password" password: "your-password"
# Clay — Claude Code web interface at agent.ladkau.de
# Generate API key at console.anthropic.com → API keys
anthropic_api_key: ""
clay_users:
- username: admin
password: "your-password"
# Gitea Actions runners — token obtained after Gitea is running
# See step 3.4 of runbook-configuration.md; replace after Gitea admin account is created
gitea_runner_registration_token: "placeholder"
# Vaultwarden password vault # Vaultwarden password vault
vaultwarden_admin_token: "" # generate: openssl rand -hex 32 vaultwarden_admin_token: "" # generate: openssl rand -hex 32
# vaultwarden_sso_client_secret is added after Keycloak is configured — see # vaultwarden_sso_client_secret is added after Keycloak is configured — see
@@ -203,20 +215,29 @@ ansible-playbook -i ansible/inventory.ini ansible/site.yml --ask-vault-pass
This runs all roles in order: This runs all roles in order:
| # | Role | What it does | | # | Role | What it does |
|---|------------|--------------| |----|---------------|--------------|
| 1 | `base` | OS hardening, deploy user, SSH config, ufw firewall, fail2ban | | 1 | `base` | OS hardening, deploy user, SSH config, ufw firewall, fail2ban |
| 2 | `docker` | Docker Engine + Compose plugin, shared Traefik network | | 2 | `docker` | Docker Engine + Compose plugin, shared Traefik network |
| 3 | `traefik` | Reverse proxy, automatic TLS via Let's Encrypt | | 3 | `traefik` | Reverse proxy, automatic TLS via Let's Encrypt |
| 4 | `gitea` | Self-hosted Git with PostgreSQL | | 4 | `gitea` | Self-hosted Git with PostgreSQL |
| 5 | `nextcloud`| File storage with PostgreSQL, Redis, cron sidecar | | 5 | `act_runner` | Three Gitea Actions runners with Docker executor |
| 6 | `sso` | Keycloak single sign-on with PostgreSQL | | 6 | `nextcloud` | File storage with PostgreSQL, Redis, cron sidecar |
| 7 | `mail` | Roundcube webmail client with PostgreSQL | | 7 | `sso` | Keycloak single sign-on with PostgreSQL |
| 8 | `registry` | Docker Registry v2 with htpasswd auth | | 8 | `mail` | Roundcube webmail client with PostgreSQL |
| 9 | `k8s` | Placeholder page at k8s.ladkau.de | | 9 | `registry` | Docker Registry v2 with htpasswd auth |
| 10 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de | | 10 | `k8s` | Placeholder page at k8s.ladkau.de |
| 11 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload | | 11 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de |
| 12 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats | | 12 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload |
| 13 | `clay` | Claude Code web interface at agent.ladkau.de — basic auth, Anthropic API key required |
| 14 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats |
> **Note:** The `act_runner` role requires `gitea_runner_registration_token` in the
> vault, which can only be obtained after Gitea is running and an admin account has
> been created. On a fresh provisioning run the role will fail if the token is
> absent. Either add a placeholder and redeploy with `--tags act_runner` after
> Gitea is configured (see step 3.4 of `runbook-configuration.md`), or skip the
> role on first run: `--skip-tags act_runner`.
To apply a single role: To apply a single role:
+1
View File
@@ -18,6 +18,7 @@ CHECKS=(
"https://k8s.ladkau.de 200 10" "https://k8s.ladkau.de 200 10"
"https://vault.ladkau.de 200 10" "https://vault.ladkau.de 200 10"
"https://dl.ladkau.de 200 10" "https://dl.ladkau.de 200 10"
"https://agent.ladkau.de 401 10"
) )
if ! command -v curl &>/dev/null; then if ! command -v curl &>/dev/null; then
+2
View File
@@ -44,6 +44,7 @@ required_scalars = [
"roundcube_db_password", "roundcube_db_password",
"roundcube_des_key", "roundcube_des_key",
"vaultwarden_admin_token", "vaultwarden_admin_token",
"anthropic_api_key",
] ]
# Required non-empty lists (must contain at least one entry) # Required non-empty lists (must contain at least one entry)
@@ -51,6 +52,7 @@ required_lists = [
"traefik_dashboard_users", "traefik_dashboard_users",
"registry_users", "registry_users",
"dovecot_users", "dovecot_users",
"clay_users",
] ]
# Optional scalars: validated only when present — (key, validator_fn, hint) # Optional scalars: validated only when present — (key, validator_fn, hint)