# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Overview Personal infrastructure as Ansible: 24 self-hosted Docker services on one server, across `thesatelliteoflove.com` and `nerder.land`. Caddy is the reverse proxy (automatic HTTPS), Pocket ID is the passkey SSO, and Tailscale is how Ansible reaches the server. See `README.md` for the service list. If a local `DEPLOYMENT_LEARNINGS.md` exists (it's gitignored), read it for past lessons. ## Working alongside other agents (read first) Several agents change this repo and deploy to the same server at once. Two things went wrong before these rules: a deploy from an unpushed branch was silently reverted by the next deploy from main, and vault edits overwrote each other. Follow these steps: 1. **Never edit the main checkout.** Start each task in its own worktree: `bin/worktree ` (based on the latest origin/main, with the vault files linked in). 2. **Land on main before deploying.** Commit, `git fetch && git rebase origin/main`, `git push origin HEAD:main`, then deploy that exact commit. `deploy-guard.yml` (imported by site.yml and dns.yml) refuses real runs from a checkout that is dirty or isn't origin/main. `--check` runs are always allowed; `-e allow_unpushed=true` is for emergencies only, and the fix must be pushed straight after. 3. **Deploy with `bin/deploy`**, not ansible-playbook directly. It holds a lock so only one deploy runs at a time (`bin/deploy --tags `; `PLAYBOOK=dns.yml bin/deploy` for DNS). Use the narrowest tag that covers your change. 4. **Edit the vault only with `bin/vault-run `.** It locks, backs up, decrypts to a private temp file passed as `$VAULT_PLAIN`, checks the YAML, re-encrypts and swaps it in at once. Never `ansible-vault edit`, and don't decrypt vault.yml in place. 5. **Check the live result after deploying** (e.g. `docker inspect`, a login test). If someone else's deploy undid yours, rebase, push, and redeploy rather than deploying an older checkout. 6. **Shared files** (Caddyfile.j2, domains.yml, dns.yml, main.yml task lists) are touched by everyone: keep edits small, rebase just before pushing, and resolve conflicts by keeping both sides. ## Common Commands ```bash bin/worktree # start a task (see above) bin/deploy --tags # deploy one service bin/deploy --tags media,productivity # deploy categories bin/deploy --tags services # every service, no system setup bin/deploy --tags common,cron # base setup and cron jobs only bin/deploy --check --diff --tags # dry run, allowed from any checkout PLAYBOOK=dns.yml bin/deploy # Route53 records bin/vault-run # edit secrets ($VAULT_PLAIN) # First-time setup of a new server (public IP, before Tailscale) .venv/bin/ansible-playbook bootstrap.yml -i hosts.yml --vault-password-file vault_pass ``` The venv (`.venv/`), `vault_pass` and `group_vars/all/vault.yml` live in the main checkout; the `bin/` helpers find them from any worktree. ## Architecture ### Host One server, `cogitator-01` (netcup VPS), listed twice in `hosts.yml`: - `netcup` (group `bootstrap`): public IP 152.53.36.98, used only by `bootstrap.yml`. - `docker-01` (group `docker`): Tailscale IP 100.70.169.99, used by `site.yml`. DNS A records point at the public IP; Caddy on the same machine routes by subdomain. ### Roles (`site.yml` runs common, docker, cron) - **bootstrap**: user, SSH key, passwordless sudo, UFW, then `artis3n.tailscale`. - **common**: `aptitude`, `jq`, daily unattended security updates, UFW on with default deny. - **docker**: Docker CE, shell setup (zsh, Atuin, bat), ufw-docker, the `lava` network, and every service. - **cron**: dockcheck (08:00, auto-updates labelled containers, Gotify notices) and the Warhammer RSS feed (09:10). ### Docker role categories (`roles/docker/tasks//`) - **infrastructure/**: Caddy, Pocket ID, Gondulf (IndieAuth), Dockge - **development/**: Gitea (+ act_runner), ByteStash - **media/**: Audiobookshelf, Kavita, Karakeep (files named `hoarder`), Manyfold - **productivity/**: Paperless-ngx, Baikal, Syncthing, HeyForm, Dawarich, Obsidian LiveSync, Gokapi - **communication/**: GoToSocial, Listmonk, StarPunk (Micropub) - **monitoring/**: Changedetection, Apprise API, Gotify (+ iGotify), Cronmaster Each category has a tag (`infrastructure`, `media`, …) and each service its own tag; `services` covers all of them. Karakeep has both `karakeep` and `hoarder` tags. ### Variables (`group_vars/all/`) - **domains.yml**: `primary_domain`, `secondary_domain`, `subdomains.`, email domains. - **infrastructure.yml**: `docker.*` (network name, subnet, fixed IPs), `smtp.*` (Resend), `network.*` (public and Tailscale IPs). - **vault.yml**: encrypted secrets, one `vault_` dict per service. Never hardcode domains, IPs or secrets in templates; use these variables. ### Data - Each service lives in `/opt/stacks//` on the server (Dockge in `/opt/dockge/`). - Compose files come from `roles/docker/templates/-compose.yml.j2`; env and config files from `-env.j2` / `.env.j2` / `-config.json.j2`. - Retired services' final backups are in `/opt/stacks/_retired/`. ## Key Implementation Details ### Adding or changing a service - Template: `roles/docker/templates/-compose.yml.j2`. - Tasks: `roles/docker/tasks//.yml`, imported with a tag in that category's `main.yml`. - Public access: subdomain in `domains.yml`, route in `Caddyfile.j2`, A record in `dns.yml`. - Use `diff: false` / `no_log: true` on tasks that write secrets. - Add the label `mag37.dockcheck.update: true` if dockcheck may update it unattended. ### DNS `dns.yml` manages Route53 A records for both domains with a Route53-only IAM user (`vault_aws_dns`). It also deletes the records listed under `removed_records`; add a retired service's subdomain there. ### Security - Caddy terminates TLS with Let's Encrypt. - `ufw-docker` makes UFW govern container ports. Only Caddy (80/443) and Gitea SSH (host port 222) are allowed in from the internet. - Admin UIs (Syncthing, Cronmaster, Apprise, CouchDB) bind to the Tailscale IP only. ### SSO (Pocket ID) Pocket ID (`id.thesatelliteoflove.com`) replaced Authentik on 2026-10-31. Apps that log in with it: Gitea, ByteStash, Audiobookshelf, Kavita, Karakeep, Manyfold, Paperless-ngx, Gokapi, GoToSocial. Some read the client from env vars; Gitea, Audiobookshelf and Kavita store it in their own database (set in their admin UI). OIDC clients are created in Pocket ID's UI and their ID and secret go in the vault as `vault_.pocketid`. StarPunk logs in with IndieAuth through Gondulf instead. ### Shared Docker Network (`lava`) Created by the docker role as `{{ docker.subnet }}` with `ip_range: {{ docker.ip_range }}`. Docker only hands out dynamic addresses from that range, so `172.20.0.x` is reserved for fixed IPs: Caddy (`docker.hairpin_ip`, .5) and Gitea (`docker.gitea_ip`, .6). Both need fixed IPs because ufw-docker firewall rules point at container IPs. A network without the range (the 2024 original) is migrated once with `migrate-lava-network.yml`. ### Hairpinning Containers can't reach domains that resolve to this server's public IP. Map any of our domains a container calls to Caddy with `extra_hosts`: ```yaml extra_hosts: - "{{ subdomains.pocketid }}:{{ docker.hairpin_ip }}" ``` Most often needed for `subdomains.pocketid` (OIDC); also `subdomains.git` (Gitea itself), `subdomains.gondulf` and `primary_domain` (StarPunk). ### Template pattern ```yaml networks: default: external: true name: "{{ docker.network_name }}" ``` ### Service-Specific Notes - **Dawarich**: based on https://github.com/Freika/dawarich/blob/master/docker/docker-compose.production.yml - **Gokapi**: `config.json` is only written on a fresh install (`force: false`); delete it on the server to apply template changes. - **Caddy**: Caddyfile changes restart it via a handler; the stack uses `recreate: never`. ## Service Memories - karakeep (formerly called hoarder) is deployed with both 'hoarder' and 'karakeep' tags for backward compatibility - whenever i ask you what containers need updates, run dockcheck and return a list of containers needing updates - when i ask for the status container updates i want you to run dockcheck on the docker host https://github.com/mag37/dockcheck?ref=selfh.st