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
This commit is contained in:
ml
2026-08-01 07:55:20 +02:00
parent 735563fc31
commit 8adbd3fac4
13 changed files with 321 additions and 1 deletions
+12
View File
@@ -75,6 +75,18 @@ 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
- `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:
+10
View File
@@ -124,3 +124,13 @@ dl_sftp_port: 2223
# Status dashboard (public — cloud.ladkau.de root)
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"
+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 }}"),
("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"),
("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"),
("Clay", "https://{{ domain_clay }}", 401, "https://{{ domain_clay }}"),
]
{% raw %}
@@ -30,6 +30,9 @@ entryPoints:
imaps:
address: ":993"
serversTransport:
insecureSkipVerify: true # allows Traefik to proxy clay's self-signed d.clay.studio cert on port 2633
providers:
docker:
exposedByDefault: false
+1
View File
@@ -15,4 +15,5 @@
- k8s
- vaultwarden
- dl
- clay
- dashboard
+162
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://vault.ladkau.de` | 200 | Vaultwarden web vault |
| `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
did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on
@@ -468,3 +469,164 @@ 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.
+10 -1
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 |
| vault.ladkau.de | A → server IP |
| dl.ladkau.de | A → server IP |
| agent.ladkau.de | A → server IP |
### 4. Create the vault and populate secrets
@@ -121,6 +122,13 @@ registry_users:
- username: alice
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"
@@ -221,7 +229,8 @@ This runs all roles in order:
| 10 | `k8s` | Placeholder page at k8s.ladkau.de |
| 11 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de |
| 12 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload |
| 13 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats |
| 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
+1
View File
@@ -18,6 +18,7 @@ CHECKS=(
"https://k8s.ladkau.de 200 10"
"https://vault.ladkau.de 200 10"
"https://dl.ladkau.de 200 10"
"https://agent.ladkau.de 401 10"
)
if ! command -v curl &>/dev/null; then
+2
View File
@@ -44,6 +44,7 @@ required_scalars = [
"roundcube_db_password",
"roundcube_des_key",
"vaultwarden_admin_token",
"anthropic_api_key",
]
# Required non-empty lists (must contain at least one entry)
@@ -51,6 +52,7 @@ required_lists = [
"traefik_dashboard_users",
"registry_users",
"dovecot_users",
"clay_users",
]
# Optional scalars: validated only when present — (key, validator_fn, hint)