personal_infra/ansible/roles/caddy_site
counterweight 16cbd189b8
ntfy, datum-gateway, headscale: use the caddy_site role
Completes Stage 3. No hand-rolled Caddy plumbing remains anywhere:
`grep sites-enabled` outside roles/ returns nothing, and so does
`grep "systemctl reload caddy"`.

ntfy uses caddy_site_body for its plain-HTTP listener and @httpget redirect.
Verified ok/unchanged against watchtower; the one other changed task is a
pre-existing "Update APT cache".

datum-gateway keeps a whole-Caddyfile validate after the role call. The role
validates its own fragment, but only a whole-file validate catches a conflict
between two sites, and this playbook was the only one that ever had it. Its
two debug tasks that echoed command output are gone with the commands.

headscale is the one that mattered. Its playbook wrote
`reverse_proxy localhost:8080`, but spacey is actually running a /admin*
route in front of Headplane behind Caddy basic auth. Running that playbook
would have deleted the admin route and its auth - a hazard that predates this
work. It now renders the config that is really there, verified ok/unchanged
via --start-at-task (the play cannot reach Caddy in check mode: "Install
headscale package" fails because the .deb is not really downloaded, before
and after this edit alike).

Supporting changes for headscale:
  - headscale_ui_password_hash added to infra_secrets.yml and the identical
    group_vars/all/vault.yml, read from the live config on spacey. The vault
    already had headscale_ui_username (= counterweight, confirmed) and
    headscale_ui_password; I did not verify the password is the plaintext of
    this hash.
  - headplane_port added to headscale_vars.yml.
  - The role's handler now sets become: true. Handlers do not inherit become
    from the task that notified them, and this play runs become: no.
  - The include uses `apply: become: yes`; `become:` on an include_role is
    rejected outright.

All 14 site files on all 3 hosts still byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:37:38 +02:00
..
defaults caddy: add the caddy_site role 2026-09-11 23:10:43 +02:00
handlers ntfy, datum-gateway, headscale: use the caddy_site role 2026-09-11 23:37:38 +02:00
tasks caddy: add the caddy_site role 2026-09-11 23:10:43 +02:00
templates caddy: add the caddy_site role 2026-09-11 23:10:43 +02:00
README.md caddy: add the caddy_site role 2026-09-11 23:10:43 +02:00

caddy_site

Writes one Caddy site file into {{ caddy_sites_dir }}, makes sure the main Caddyfile imports that directory, validates the result, and reloads Caddy once.

Replaces the four-task block that was copy-pasted into 10 playbooks.

Runs on any host in the [caddy] group — edge (vipy), monitoring (watchtower) and vpn_control (spacey).

Usage

- ansible.builtin.include_role:
    name: caddy_site
  vars:
    caddy_site_name: forgejo                     # -> forgejo.conf
    caddy_site_domain: "{{ forgejo_domain }}"
    caddy_site_upstream: "localhost:{{ forgejo_port }}"

Use include_role, not a roles: block, so the call stays in task order next to the tasks it depends on. Variables passed this way are scoped to the include and do not leak into later calls — so every call must pass everything it needs; nothing carries over.

Shapes

Pick exactly one of caddy_site_upstream, caddy_site_root, caddy_site_body.

Want Set
reverse_proxy host:port caddy_site_upstream
static root * + file_server caddy_site_root
anything else caddy_site_body (raw, indented 4 for you)

caddy_site_upstream accepts two modifiers, which add a block to the reverse_proxy:

  • caddy_site_headers_up: {"X-Forwarded-Host": "..."}
  • caddy_site_resolvers: "100.100.100.100" — Tailscale MagicDNS

and caddy_site_basic_auth wraps the site in a basic_auth block.

caddy_site_basic_auth is a LIST, not a dict

caddy_site_basic_auth:
  - user: "{{ datum_dashboard_username }}"
    hash: "{{ datum_dashboard_password_hash }}"

Ansible does not template dictionary keys. With { "{{ user }}": "hash" } the value is rendered and the key is not, so the literal string {{ datum_dashboard_username }} lands in the config file. Found while building this role; the assert refuses a mapping so it cannot happen again.

Secrets and --diff

Rendered site files can carry credentials — datum-gateway.conf holds a bcrypt hash — and --diff prints rendered content. The template task therefore sets diff: "{{ caddy_site_reveal | bool }}", default false, so --diff runs are safe everywhere. Pass -e caddy_site_reveal=true to see what moved on a site you know is not secret.

Validation

validate: "caddy validate --adapter caddyfile --config %s" runs against the rendered temp file before it is moved into place. Verified on vipy that a single site fragment validates cleanly (rc=0, Valid configuration) and that a malformed one is rejected (rc=1, with the syntax error and line number). A failed validate leaves the live file untouched, so a broken config can no longer reach a running Caddy.

What it cannot catch is a conflict with the global /etc/caddy/Caddyfile.

The reload is a handler

Reload caddy fires once, at the end of the play, however many sites notified it. The code this replaced ran command: systemctl reload caddy immediately, mid-play. If a later task in the same play needs the new config to be live, flush first:

- ansible.builtin.meta: flush_handlers

Known intentional difference

The resolvers block is commented # Use Tailscale MagicDNS to resolve the upstream hostname in every case. datum-gateway previously said # Resolve via Tailscale MagicDNS. Migrating it therefore rewrites one comment line, which Caddy ignores. Every other site renders byte-identical to what its playbook produced.