# `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 ```yaml - 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 ```yaml 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: ```yaml - 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.