Files
ansible/CLAUDE.md
T
PhilandClaude Opus 5.5 0c14ee5b1d Bring the docs up to date with the running stack
The READMEs, CLAUDE.md and todo.md still described Authentik, services
removed long ago (Ghost, MMDL, Postiz, Pinchflat...) and two servers. Now
they list the 24 services actually deployed (checked against docker ps),
explain that netcup and docker-01 are one machine, document Gondulf,
StarPunk, Listmonk, Gokapi and dockcheck, and use bin/deploy throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:30:50 -06:00

8.4 KiB

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 <branch-name> (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 <service>; PLAYBOOK=dns.yml bin/deploy for DNS). Use the narrowest tag that covers your change.
  4. Edit the vault only with bin/vault-run <command>. 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

bin/worktree <branch>                  # start a task (see above)
bin/deploy --tags <service>            # 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 <tag> # dry run, allowed from any checkout
PLAYBOOK=dns.yml bin/deploy            # Route53 records
bin/vault-run <command>                # 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, 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/<category>/)

  • 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.<service>, email domains.
  • infrastructure.yml: docker.* (network name, subnet, fixed IPs), smtp.* (Resend), network.* (public and Tailscale IPs).
  • vault.yml: encrypted secrets, one vault_<service> dict per service.

Never hardcode domains, IPs or secrets in templates; use these variables.

Data

  • Each service lives in /opt/stacks/<service>/ on the server (Dockge in /opt/dockge/).
  • Compose files come from roles/docker/templates/<service>-compose.yml.j2; env and config files from <service>-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/<service>-compose.yml.j2.
  • Tasks: roles/docker/tasks/<category>/<service>.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_<service>.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:

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

networks:
  default:
    external: true
    name: "{{ docker.network_name }}"

Service-Specific Notes

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