The gondulf DNS record is deleted (checked against public DNS), so its removed_records entry is no longer needed. vault_gondulf was also removed from the vault. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
8.4 KiB
Markdown
168 lines
8.4 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, 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
|