Karakeep had DISABLE_SIGNUPS unset, so anyone could make an account on bookmarks. and use the OpenAI key behind it. The server also had no automatic updates and three security updates waiting; the common role now installs unattended-upgrades with Debian's default security-only origins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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:
- 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). - 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.--checkruns are always allowed;-e allow_unpushed=trueis for emergencies only, and the fix must be pushed straight after. - 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/deployfor DNS). Use the narrowest tag that covers your change. - 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. Neveransible-vault edit, and don't decrypt vault.yml in place. - 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. - 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(groupbootstrap): public IP 152.53.36.98, used only bybootstrap.yml.docker-01(groupdocker): Tailscale IP 100.70.169.99, used bysite.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
lavanetwork, 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'smain.yml. - Public access: subdomain in
domains.yml, route inCaddyfile.j2, A record indns.yml. - Use
diff: false/no_log: trueon tasks that write secrets. - Add the label
mag37.dockcheck.update: trueif 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-dockermakes 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
- Dawarich: based on https://github.com/Freika/dawarich/blob/master/docker/docker-compose.production.yml
- Gokapi:
config.jsonis 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