Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8adbd3fac4 | |||
| 735563fc31 | |||
| 22225a304f | |||
| cc01a61dc3 | |||
| 883893f755 | |||
| c968995481 |
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -204,19 +216,28 @@ 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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user