Files
ansible/CLAUDE.md
T
PhilandClaude Opus 5.5 dff614b481 Turn off SSH password and root login
sshd accepted passwords from the internet (about 12,000 failed logins a
day) and root logins with a key. Keys only now, no root, no X11 forwarding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:17:45 -06:00

168 lines
8.5 KiB
Markdown

# 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
```bash
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`, daily unattended security updates, SSH keys only (no root login), 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, Dockge
- **development/**: Gitea (+ act_runner and rootless BuildKit; the runner has no Docker socket), 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, StarPunk. 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 (v2.0.0+) also
runs its own IndieAuth server so Micropub apps can post.
### 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) 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