caddy/inventory: serve familie.gierz.eu + off-LAN gateway default #1

Open
erwin wants to merge 1 commit from erwin/familie-routing into main
First-time contributor

Surgical, additive routing for the new familie.gierz.eu static site (content in homelab/sites). No multi-site refactor.

Deployed live 2026-06-28 (edge + gateway) — this PR syncs git with production.

What's here

  • caddy(gateway)http://familie.gierz.eu serves /var/www/familie.gierz.eu; familie.gierz.eu added to landing_sites.
  • caddy(edge)familie.gierz.eu terminates TLS at edge, proxies plain HTTP over WG to the gateway. Resolves via the live *.gierz.eu wildcard, so no DNS change needed.
  • inventory — default gierz-gateway to the gierz-gateway-wg ssh alias (edge → WireGuard → gateway). Works on and off the home LAN since edge is public, so converges no longer need a hypervisor-LAN route or an -e override. Interim until CD runs from inside the network.

@erwin

🤖 Generated with Claude Code

Surgical, additive routing for the new `familie.gierz.eu` static site (content in `homelab/sites`). **No multi-site refactor.** **Deployed live 2026-06-28** (edge + gateway) — this PR syncs git with production. ### What's here - **caddy(gateway)** — `http://familie.gierz.eu` serves `/var/www/familie.gierz.eu`; `familie.gierz.eu` added to `landing_sites`. - **caddy(edge)** — `familie.gierz.eu` terminates TLS at edge, proxies plain HTTP over WG to the gateway. Resolves via the live `*.gierz.eu` wildcard, so no DNS change needed. - **inventory** — default `gierz-gateway` to the `gierz-gateway-wg` ssh alias (edge → WireGuard → gateway). Works on **and** off the home LAN since edge is public, so converges no longer need a hypervisor-LAN route or an `-e` override. Interim until CD runs from inside the network. — @erwin 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bottom is in neither Fedora's default repos nor Rocky's EPEL, so unlike
eza/starship/zoxide the upstream-binary install runs on every distro
(the btm block is intentionally not gated on distribution != 'Fedora').
It stands alone with its own cache-dir ensure + stat guard since it
can't reuse the Fedora-gated tasks. include: [btm] extracts only the
binary so the tarball's completion/ subtree stays out of /usr/local/bin.

Co-Authored-By: LLM <noreply@invalid>
New role + Quadlet container exposing the headscale daemon as a
browser UI at vpn.findorff.gierz.eu/admin/. Existing Tailscale
client routes (everything else under vpn.findorff) continue to
hit headscale's HTTP API unchanged — only the /admin/ subtree
gets routed to headplane on services VM port 8084.

- roles/headplane/ — full role: defaults, tasks, handlers,
  Quadlet, config.yaml, render-secrets wrapper.
- API key minted once via 'podman exec headscale apikeys create'
  and persisted to /etc/headplane/credentials/.
- Authentik OIDC client registered (slug=headplane,
  redirect_uri=/admin/oidc/callback).
- Caddy gateway: /admin* path on vpn.findorff -> headplane :8084.
- Portal tile description updated to /admin/.
- gai.conf bind-mount so the upstream Authentik OIDC discover
  lookup prefers IPv4 (same pattern as forgejo/synapse/mas).

Co-Authored-By: LLM <noreply@invalid>
Static React SPA from vectorim/element-web Quadlet on services VM,
default homeserver pointed at matrix.findorff.gierz.eu. Element X
on mobile has no public-rooms browser; this fills the gap for
desktop / iPad browsing.

Runtime quirks:
- Upstream nginx image refuses ReadOnly=true (envsubst writes
  /etc/nginx/conf.d/default.conf at startup).
- Non-root nginx user can't bind port 80 without CAP_NET_BIND_SERVICE.
Both addressed in Quadlet.

Caddy gateway + edge: element.findorff.gierz.eu -> services:8083.
Cloudflare DNS records added. Portal tile updated (synapse +
element-web alongside each other).

Co-Authored-By: LLM <noreply@invalid>
Rename tailscale_subnet_router -> tailscale_client so laptop, hypervisor,
and gateway can each join the tailnet as their own node. Gateway keeps
its subnet-routing duty via per-host advertise_routes; the others run as
plain clients.

- Per-host vars (hostname, advertise_routes, accept_dns, authkey) live
  in inventory/host_vars/<host>.yml; auth keys are
  vault_tailscale_<host>_authkey.
- New `tailnet` inventory group; playbooks/headscale.yml's second play
  now targets it.
- IPv4 forwarding sysctl only applied when routes are advertised;
  firewalld task soft-fails (laptop has none).
- Drop orphan gateway_tailscale_subnet_router var.

Co-Authored-By: LLM <noreply@invalid>
Apex + www + matrix + element + social subdomains, all pointing at
the edge VPS for TLS termination, mirroring the gierz.eu pattern.

Punycode form xn--hring-jua.eu is what lands in MXIDs and fedi
handles — Matrix spec forbids Unicode in server_name and the
fediverse handles IDN poorly. Cloudflare zone is the literal so
records read naturally; CF normalises internally.

Email Routing (flavia@höring.eu → flavia.hoering@gmail.com) is
managed in the CF dashboard, not via DNS records here.

Co-Authored-By: LLM <noreply@invalid>
Second libvirt VM on the hypervisor, parallel to gierz-services. Hosts
Flavia's stack — Synapse + Element Web + GoToSocial for the höring.eu
(xn--hring-jua.eu) domain. Single-instance roles run unchanged; per-tenant
identity comes from host_vars.

Naming: ASCII alias `hoering-services` (umlaut would break vault-key
derivation and other tools). User-facing identifiers (DNS, MXIDs, fedi
handles) keep `höring.eu`.

Scaffolding only — nothing live yet:
- tofu/hoering-services/{main,versions}.tf — 3 GiB / 2 vCPU / 50 GiB
  data volume. MAC 52:54:00:a0:7e:40 → 192.168.122.40.
- cloud-init/hoering-services/{meta-data,user-data.j2} — references
  vault_hoering_services_ssh_host_keys (to be generated).
- playbooks/render-cloud-init.yml — adds the new VM to the render loop.
- roles/libvirt_host/files/default-network.xml — static-DHCP reservation.
- inventory/hosts.yml — `hoering-services` host + `hoering` per-tenant
  child group under `services:`.
- inventory/host_vars/hoering-services.yml — services_vm_base config
  + restic paths for the three tenant services. Per-service identity
  vars documented as comments; filled in when each role is wired up.

Co-Authored-By: LLM <noreply@invalid>
Move the reservation from the network XML (where it's dead code — see
the role's comment about net-define being gated on 'not yet defined')
to libvirt_host_dhcp_reservations, where the role applies it live via
virsh net-update.

Reverts the earlier XML edit from the scaffold commit.

Co-Authored-By: LLM <noreply@invalid>
Rocky 10 ships aardvark-dns 1.16.0; the inotify-based resolv.conf
self-heal landed upstream in 1.17.0 (Nov 2025). Without it, every new
podman network we add risks orphaning existing containers' external DNS
— we've hit this twice now (authentik, then synapse, both producing
503s once token introspection or OIDC flows tried to leave the netns).

Two-track fix:

1. Drop /etc/yum.repos.d/rhcontainerbot-podman-next.repo with
   `includepkgs=aardvark-dns` so the COPR only supplies that one binary
   — podman + netavark stay on the distro. Drop the repo file (or relax
   includepkgs) once RHEL 10 carries ≥ 1.17.0 itself.

2. Shared cross-role handler `kill aardvark-dns` in services_vm_base.
   Belt-and-braces against any inotify edge case 1.17 doesn't cover;
   per-role wiring lands in the next commit.

Co-Authored-By: LLM <noreply@invalid>
Instead of editing 13 per-role files to notify the kill handler, the
kill handler itself listens on every existing Quadlet daemon-reload
handler name. Any task already notifying its role's reload handler now
also triggers the cross-role aardvark respawn — no per-role wiring.

Adding a new Quadlet role: append its daemon-reload handler name to the
listen list. Single point of change.

Co-Authored-By: LLM <noreply@invalid>
Three Quadlets (server + postgres + redis/valkey) on a per-service
podman network. No ML container — face/object recognition off; add
immich-machine-learning later if smart search is wanted.

Storage lives on the services VM data volume at
/srv/services/state/immich/library. Grow vdb when phone libraries fill
it ("virsh blockresize services vdb <N>G" → "xfs_growfs /srv/services"
in guest).

OIDC configured in Immich's admin UI on first run — env-based OAuth
was removed upstream around v1.110. The client_secret still lives
on-host via machine_credential at /etc/authentik/oidc/immich-client-secret
so the UI value is reproducible across rebuilds.

Pattern mirrors the gotosocial role: machine_credential for the
postgres password, render-immich-secrets.sh ExecStartPre composes
/run/immich/*-secrets.env at every container start, non-secret config
in /etc/immich/{server,postgres}.env.

Code-only — DNS, Caddy vhosts on edge+gateway, Authentik application,
and restic backup paths land in follow-up commits.

Co-Authored-By: LLM <noreply@invalid>
Plumbing for the immich role committed previously. With these the
service is reachable end-to-end the moment you run the playbook:

- cloudflare_dns: photos.findorff.gierz.eu A + AAAA on the edge IPs.
- Caddy (edge): photos.findorff.gierz.eu vhost — TLS termination,
  50 GB request_body ceiling for full-res phone-video uploads.
- Caddy (gateway): http://photos.findorff.gierz.eu → 192.168.122.30:8085.
- authentik_oidc_apps: `immich` slug. Web callback + the app.immich:/
  native deep link so iOS/Android clients can complete the handshake.
  Blueprint renders on next authentik converge; client_secret
  generated by machine_credential at /etc/authentik/oidc/.
- unified_portal: tile under the services group. No sso_url — Immich's
  OAuth is set via its admin UI on first run, not at a stable URL.
- gierz-services restic: back up originals + library + profile + /etc;
  exclude thumbs/ and encoded-video/ (regenerable from originals).

To activate end-to-end:
  ansible-playbook playbooks/cloudflare.yml
  ansible-playbook -K playbooks/edge.yml -K playbooks/gateway.yml
  ansible-playbook -K playbooks/authentik.yml
  ansible-playbook -K playbooks/immich.yml
  ansible-playbook -K playbooks/unified_portal.yml

Then in Immich admin UI: paste the client_secret from
/etc/authentik/oidc/immich-client-secret into OAuth settings.

Co-Authored-By: LLM <noreply@invalid>
The initial role pushed OAuth wiring onto the admin UI on the false
premise that env-based OAuth had been removed upstream. It hasn't —
Immich supports a declarative YAML config via IMMICH_CONFIG_FILE
(docs.immich.app/install/config-file), and that file's oauth section
holds enabled/issuerUrl/clientId/clientSecret/scope/etc.

Wire it properly:

- New templates/config.yaml.j2 — full oauth block, machineLearning
  off, newVersionCheck off, trash 30d, mobileOverrideEnabled so the
  iOS/Android client uses the SAME auth/login web callback that's
  registered in Authentik (server bounces to app.immich:/ internally).
- render-immich-secrets.sh: python3+PyYAML substitutes oauth.clientSecret
  in a runtime copy at /run/immich/config.yaml. /etc/immich/config.yaml
  is the source template with an empty secret; never read by the
  container. sed on YAML avoided on principle.
- immich-server.container: mount /run/immich/config.yaml → /config/
  config.yaml read-only; server.env points IMMICH_CONFIG_FILE there.
- server.env: dropped IMMICH_SERVER_URL (moved to config.server.
  externalDomain) and the bogus IMMICH_MACHINE_LEARNING_URL=false
  (machineLearning.enabled false in the config file is the real knob).
- authentik blueprint: drop the app.immich:/ redirect_uri — with
  mobileOverrideEnabled true the OIDC provider only needs the web
  callback.

End-state: nothing for the admin to paste. authentik converge → immich
converge → first OAuth login JustWorks.

Co-Authored-By: LLM <noreply@invalid>
Edge gets TLS termination + apex Matrix/WebFinger delegation; gateway
gets the http:// upstream blocks pointing at hoering-services
(192.168.122.40). Site labels use punycode (xn--hring-jua.eu) because
browsers send the A-label as SNI for umlaut hostnames and Caddy needs
exact-match for cert issuance + routing — the umlaut in the address
bar is purely a UI render.

Edge:
- xn--hring-jua.eu apex + www: serves /.well-known/matrix/{server,client}
  (federation handle is @flavia:xn--hring-jua.eu, no MAS in this tenant),
  redirects /.well-known/webfinger to social. so the JSON comes from one
  place, otherwise 302 to element.
- matrix., element., social.: TLS-terminate + proxy over WG.

Gateway:
- matrix./element./social.xn--hring-jua.eu → 192.168.122.40:{8008,8083,8081}
  (default per-role ports, since hoering-services runs single-instance
  roles, not a multi-tenant refactor).

Nothing wires up until hoering-services is provisioned + the synapse/
element_web/gotosocial playbooks run against it (task #5, #6).

Co-Authored-By: LLM <noreply@invalid>
Single-node Garage at:
  - s3.findorff.gierz.eu     — S3 API (port 3900)
  - garage.findorff.gierz.eu — S3-website endpoint (port 3902, apex only)

Storage on a dedicated qcow2 (vdc, 200 GiB, in the libvirt data pool)
mounted at /srv/garage. Isolated from /srv/services so a fill on S3
buckets can't strangle Postgres / git repos.

Role pattern mirrors gotosocial/immich:
  - machine_credential for rpc_secret (32-byte hex)
  - render-garage-secrets.sh ExecStartPre substitutes rpc_secret into
    /run/garage/garage.toml at every container start; /etc copy is the
    source with an empty secret, never read by the container
  - Quadlet mounts the runtime config and the storage dirs
  - admin port (3903) bound 127.0.0.1 only — podman-exec access only

Includes the format+mount task (XFS, label=grg) gated on a blank vdc
so re-runs are no-ops. cloud-init's fs_setup learns vdc too for future
rebuilds. Restic backs up /etc/garage and the meta/ catalog only — the
data/ tree IS the backup destination for everything else, backing it
up here would be circular.

Bootstrap (one-time, after first converge — see role's post-install
debug message):
  podman exec garage /garage status   # find NODE_ID
  podman exec garage /garage layout assign -z dc1 -c 100G <NODE_ID>
  podman exec garage /garage layout apply --version 1
  podman exec garage /garage key create <key>
  podman exec garage /garage bucket create <bucket>
  podman exec garage /garage bucket allow --read --write --owner <bucket> --key <key>

Wildcards under garage.findorff.gierz.eu are NOT exposed — that needs
DNS-01 ACME and a CF API token, deferred until per-bucket web hosting
actually wanted.

Code-only — VM disk attachment is `tofu apply`, layout-apply is
`podman exec`.

Co-Authored-By: LLM <noreply@invalid>
Two Quadlets (app + postgres) on a per-service podman network. Pattern
mirrors gotosocial/immich/garage:
  - machine_credential for postgres-password + Django SECRET_KEY
  - render-tandoor-secrets.sh ExecStartPre composes /run/tandoor/*-
    secrets.env at every container start
  - non-secret config in /etc/tandoor/{tandoor,postgres}.env
  - signup off (ENABLE_SIGNUP=0) — all logins via Authentik

Plumbing:
  - cloudflare_dns: recipes.findorff A + AAAA on the edge IPs.
  - Caddy edge: TLS-terminate + 100 MB body limit (recipe photos, no
    video). Gateway: http:// upstream to 192.168.122.30:8086.
  - authentik_oidc_apps: `tandoor` slug. Redirect URI is the django-
    allauth openid_connect callback path.
  - unified_portal: tile under the services group.
  - gierz-services restic: back up postgres + mediafiles + /etc;
    exclude staticfiles (regenerable from image).

Two manual bootstrap steps (documented in the role's post-install
debug + the playbook comment):
  1. `manage.py createsuperuser` in the container.
  2. /admin/socialaccount/socialapp/ → Add the OpenID Connect
     provider, paste the client_secret from
     /etc/authentik/oidc/tandoor-client-secret.

Tandoor doesn't expose SOCIALACCOUNT_PROVIDERS as an env var, so the
SocialApp record can't be declared from ansible the way immich's
config.yaml can. Same constraint shows up in any django-allauth
deployment — accepted, not a hack.

Co-Authored-By: LLM <noreply@invalid>
Two real problems land in one fix:

1. **Grafana white page / no metrics — root cause: no datasources.**
   The role's README has this as TODO. Grafana boots clean, the SQLite
   has empty datasources table, the UI renders white because every
   dashboard query targets a datasource that doesn't exist. Add
   templates/grafana-datasources.yaml.j2 (Prometheus + Loki resolving
   each other by container name on the shared observability network),
   render it to /etc/observability/grafana-provisioning/datasources/
   default.yaml, mount the whole provisioning tree into the container
   at /etc/grafana/provisioning. UID-based, so re-rendering doesn't
   duplicate.

2. **Loki had no log shipper.** Container was running and reachable
   but nothing was pushing logs in. New `alloy` role (Grafana Alloy
   v1.10 — replaces Promtail as of Loki 3.x):
   - Single Quadlet on the observability network so it resolves `loki`
     by name when co-located on services VM.
   - Reads /run/log/journal + /var/log/journal (RO bind-mounts).
   - discovery.relabel hoists __journal__systemd_unit → unit,
     __journal__hostname → host, __journal_priority → priority so
     LogQL queries can filter by service / severity.
   - Persistent journal enforced (creates /var/log/journal with
     systemd-journal group — journald auto-switches to persistent
     storage when the dir exists).
   - Off-box hosts (edge, gateway, gatekeeper, hoering-services)
     apply the role with an override pointing alloy_loki_endpoint at
     gierz-services:3100/loki/api/v1/push.

After convergence: white page goes away (Prometheus + Loki appear as
selectable datasources), and Explore → Loki shows journald entries
from the services VM in real time.

Co-Authored-By: LLM <noreply@invalid>
Two pieces:

1. Headscale log.format text → json. LogQL queries get structured
   fields free: `{unit="headscale.service"} | json | node_name="..."`.
   Raw journalctl is uglier but Grafana renders structured logs well.

2. Alloy on gateway, gatekeeper, hypervisor, hoering-services (when
   the VM lands). Refactor alloy.container to support both network
   modes:
     - co-located (services VM): Network=observability, resolves
       `loki` by container name.
     - off-box: Network=host, ships to
       http://192.168.122.30:3100/loki/api/v1/push.
   Per-host overrides in host_vars set alloy_network_name=host and
   the loki endpoint. New playbooks/alloy.yml targets the four
   off-box hosts in one converge.

   observability role opens Loki's host port 3100 in firewalld so the
   off-box shippers can ingest. Loki has auth_enabled=false; access
   restricted by firewalld + libvirt subnet boundary.

Edge (Hetzner) skipped intentionally — edge → services routes via
the WG tunnel and isn't trivially symmetric. Public-internet auth
logs are interesting; revisit when we want them in Loki.

After convergence: every libvirt VM's systemd journal lands in Loki
with stable labels (instance, unit, host, priority). SSH attempts,
service restarts, kernel events, every podman container's stdout —
all queryable from one Grafana Explore tab.

Co-Authored-By: LLM <noreply@invalid>
Wire up the alerting foundation. Three new pieces on the services VM,
all on the existing observability podman network:

- Alertmanager (port 9093) — routes via ntfy's built-in `alertmanager`
  X-Template (no bridge container). Two routes: warning → topic
  homelab-alerts priority 3, critical → same topic priority 5
  with rotating_light tag. 12h repeat for warnings, 4h for critical.
  Inhibits warnings while critical for same instance fires.
- Blackbox exporter (port 9115, host-localhost) — single http_2xx
  module probes 13 public hostnames; metric
  probe_ssl_earliest_cert_expiry feeds the cert-expiry rules.
- Prometheus alert rules in roles/observability/files/rules/:
  - system.yml: NodeDiskFill{Warning,Critical} (85%/95% root + /srv),
    NodeMemoryPressure (90% for 10m), NodeDown (up==0 for 5m),
    ContainerCrashloop (>3 'activating' transitions in 5m).
  - services.yml: TLSCertExpiring{Soon,Critical} (14d / 3d),
    CaddyHigh5xx (>5% for 10m), SynapseFederationQueueBackedUp
    (>1000 pending PDUs for 15m), HeadscaleNodeOffline (>24h).

Plumbing:
- prometheus.yml.j2 rewritten to support per-scrape metrics_path,
  scheme, bearer_token_file, basic_auth, params, relabel_configs —
  needed for the blackbox indirection pattern (targets are URLs to
  probe; __address__ relabel sends the request to blackbox-exporter).
  Adds rule_files glob + alerting.alertmanagers stanza.
- prometheus.container mounts /etc/prometheus/rules read-only.
- Default scrape configs add alertmanager + the blackbox_http job
  (probes the public hostname list in observability_blackbox_probe_targets).
- Firewalld opens 9093.
- Caddy edge + gateway vhosts for alertmanager.findorff.gierz.eu
  (DNS + reverse_proxy). UI is raw for now; forward_auth via
  Authentik to follow when the unified portal pattern is generalised.

Manual one-time after first converge:
- Create vault_ntfy_alerts_token (ntfy access token with publish-only
  ACL on `homelab-alerts`). Empty default works against vanilla
  anonymous-permissive ntfy; tighten when ntfy ACL is set.
- Subscribe to the `homelab-alerts` topic on phone for push.

Deferred (TODO in defaults + services.yml comments):
- Restic exporter sidecar — needs SSH-to-hydrogen creds wired into a
  third container; ResticBackupStale rule commented out until then.
- Postgres exporter sidecars per DB-bearing service — separate commit.

Co-Authored-By: LLM <noreply@invalid>
Enable Prometheus metrics on every service that exposes them, add
scrape entries in gierz-services host_vars, open the new ports in
firewalld where needed.

Per-service changes:
- synapse: add `type: metrics` listener on 9008 + enable_metrics:true
  in homeserver.yaml. PublishPort 9008. Scrape path /_synapse/metrics.
- headscale: flip metrics_listen_addr 127.0.0.1→0.0.0.0:9090, publish
  on host:9098. Firewall opens 9098 alongside 8089.
- gotosocial: add metrics-enabled:true (auth off) to config.yaml.
  Endpoint on the main HTTP port (8081/metrics).
- garage: admin api_bind_addr 127.0.0.1→[::]:3903 so /metrics is
  reachable; PublishPort 127.0.0.1:3903 (host-localhost only — admin
  API has destructive verbs, don't expose to LAN).
- forgejo: FORGEJO__metrics__ENABLED=true. Token-less; relies on
  firewalld + libvirt boundary for access control.
- authentik: PublishPort 9301:9300 on server, 9302:9300 on worker
  (since 2025.8 task metrics moved server → worker). Firewall opens
  both alongside the main HTTP port.
- caddy (gateway): global `servers { metrics }` + bare `:9019 {
  metrics }` site block. Gated on caddy_metrics_port_enabled in
  host_vars (true for gierz-gateway). Firewall opens 9019.

Scrape config in gierz-services host_vars covers all of the above
plus node_exporter on gierz-gateway (192.168.122.10:9100) and the
blackbox_http indirection job (probes the public hostname list via
blackbox-exporter, with the standard __address__ relabel pattern).

Edge Caddy intentionally skipped — services-VM→edge routing isn't
symmetric (no WG mesh, only edge→gateway). Revisit if/when that
changes.

Co-Authored-By: LLM <noreply@invalid>
Drop the blank-Grafana-after-login problem. Five dashboards fetched
from upstream at converge:

  - node-exporter-full (grafana.com/1860 r45) — canonical system view
    (CPU/mem/disk/net) for every node_exporter target.
  - logging-loki-v3 (grafana.com/24574 r2) — Loki log volume by
    container/host/severity with regex exclusions.
  - caddy-monitoring (grafana.com/22806 r1) — Prometheus + Loki pane
    for request rate / upstream health (gateway Caddy now exposes
    metrics on :9019).
  - forgejo-gitea (grafana.com/17802 r3) — git forge counters.
  - synapse (raw.githubusercontent / element-hq/synapse master) —
    canonical homeserver dashboard, kept in lockstep with Synapse.

Pattern:
- grafana-dashboards-provider.yaml.j2 — provisioning provider that
  watches /etc/grafana/dashboards/ every 30s; allowUiUpdates so
  experiments stick in SQLite but converges re-pin canonical JSON.
- get_url fetches each dashboard to host's dashboards dir with
  force:false (URL pin = revision pin, so re-fetch only happens when
  defaults are bumped).
- grafana.container mounts dashboards dir read-only at the path the
  provider yaml expects.

Deferred:
- postgres-overview (grafana.com/14114) — needs postgres-exporter
  sidecar that doesn't exist yet.
- cadvisor (grafana.com/19792) — same, no cAdvisor running.
- garage — no community dashboard; vendoring from source deferred.

Closes the "Grafana is wired but useless" gap. After convergence:
log in, every dashboard renders, every panel populates from the
existing Prometheus + Loki datasources.

Co-Authored-By: LLM <noreply@invalid>
Update top-level site.yml to import every active playbook in
dependency order, plus add alloy.yml (off-box log shippers) and the
new site_hoering.yml placeholder for Flavia's tenant.

services.yml grows to include every Quadlet built this sprint:
  - element_web (Matrix client)
  - garage (S3 object storage)
  - immich (photo backup)
  - tandoor (recipes)
  - unified_portal (findorff.gierz.eu landing)
  - alloy (journald → Loki, co-located with Loki on services VM)

site.yml flow:
  workstation → hypervisor → edge → gateway → gatekeeper → services
  → alloy (off-box hosts) → cloudflare

DNS last so flips happen after the targets are healthy. Per-service
playbooks remain runnable standalone for targeted redeploys.

site_hoering.yml is a placeholder — commented out in site.yml until
the hoering-services VM is provisioned. Imports the subset of roles
Flavia's tenant needs (Synapse, Element Web, GoToSocial + foundation
+ alloy); central observability on gierz-services scrapes her too
once the VM is reachable.

Single command for a full converge: `ansible-playbook -K site.yml`.

Co-Authored-By: LLM <noreply@invalid>
Third libvirt VM on the hypervisor (after gierz-services and
hoering-services). Hosts the family "Drive" — Nextcloud for files +
CalDAV + CardDAV, OnlyOffice Document Server for collaborative
docx/xlsx editing. Separate VM because adding both to gierz-services
would put it under memory pressure (Authentik + Synapse + the rest
already use ~4 GiB of 6 GiB; Nextcloud + OnlyOffice add ~3.5 GiB).

Scaffolding only — nothing live yet:
- tofu/gierz-cloud/{main,versions}.tf — 4 GiB / 2 vCPU / 100 GiB
  data volume (vdb). MAC 52:54:00:c1:0d:50 → 192.168.122.50.
- cloud-init/gierz-cloud/{meta-data,user-data.j2} — references
  vault_gierz_cloud_ssh_host_keys (to be generated, same workflow
  as the other VMs).
- playbooks/render-cloud-init.yml — extends the render loop.
- libvirt_host: DHCP reservation for the new MAC.
- inventory/hosts.yml — `gierz-cloud` host + new `cloud` group
  under `services:` so per-role playbooks can target it cleanly.
- inventory/host_vars/gierz-cloud.yml — services_vm_base config
  (data volume at /srv/cloud), restic paths for nextcloud +
  onlyoffice, alloy log shipper override.

Roles (nextcloud, onlyoffice) + DNS + Caddy land in the next commit.

Co-Authored-By: LLM <noreply@invalid>
Two roles + plumbing. Closes the biggest family-facing gap.

Nextcloud (cloud.findorff.gierz.eu):
- Three Quadlets — app (nextcloud:31-apache), postgres:16-alpine,
  valkey:8-bookworm. Per-service podman network.
- machine_credential for postgres password, redis password, bootstrap
  admin password, AND the shared JWT secret OnlyOffice will read.
- nextcloud-cron systemd timer fires `occ` every 5m (background
  scans, share cleanup, notifications).
- Trusted-proxy CIDRs (192.168.122.0/24 + 10.10.0.0/24) so
  X-Forwarded-* from gateway Caddy renders https:// URLs correctly.
- OIDC via the `user_oidc` Nextcloud app — config is post-install
  `occ user_oidc:provider authentik …`, documented in the role
  tasks/main.yml's header.

OnlyOffice Document Server (office.findorff.gierz.eu):
- Single fat container — bundles its own Postgres + RabbitMQ + Redis.
- Joined to the `nextcloud` podman network so Nextcloud's integration
  app reaches it as `http://onlyoffice/` from inside.
- JWT-signed callbacks — reads the shared secret from the path
  nextcloud generated via machine_credential. Asserts the file
  exists at converge time; the role play orders nextcloud before
  onlyoffice for this reason.

Plumbing:
- cloudflare_dns: cloud + office subdomains.
- Caddy edge: TLS-terminate, 50 GB request_body (phone video sync).
- Caddy gateway: http:// upstream to 192.168.122.50:{8080,8087}.
- authentik_oidc_apps: `nextcloud` slug with the two user_oidc
  callback URIs (with + without index.php prefix).
- unified_portal: tiles for nextcloud + onlyoffice. sso_url on
  nextcloud points at user_oidc/login/1 for one-click SSO.
- playbooks/site_cloud.yml — runs the foundation + alloy +
  nextcloud + onlyoffice on the cloud group.
- site.yml — commented import for site_cloud (uncomment when VM up).

Bootstrap (manual, post-first-converge — full commands in
roles/nextcloud/tasks/main.yml header):
  1. Visit https://cloud.findorff.gierz.eu, log in as bootstrap admin
     (password in /etc/nextcloud/credentials/admin-password on the VM).
  2. `occ app:install user_oidc` + `occ user_oidc:provider authentik …`.
  3. `occ app:install calendar contacts onlyoffice`.
  4. `occ config:app:set onlyoffice DocumentServerUrl --value=https://office.findorff.gierz.eu/`
  5. `occ config:app:set onlyoffice jwt_secret --value="$(< /etc/nextcloud/credentials/onlyoffice-jwt)"`

Co-Authored-By: LLM <noreply@invalid>
Three small services on gierz-services VM, all roughly the same shape
as gotosocial/tandoor/immich. Combined ~380 MB RAM.

Workout-Tracker (fit.findorff.gierz.eu):
- jovandeginste/workout-tracker, single Go binary, SQLite.
- Imports GPX/FIT, strength + cardio + PRs, multi-user.
- No native OIDC upstream; local accounts only. forward_auth via
  Authentik is a future Caddy enhancement.
- machine_credential for the JWT session-cookie key.

HortusFox (garden.findorff.gierz.eu):
- danielbrendel/hortusfox-web, plant inventory + watering schedules
  + photo log, purpose-built family garden tracker.
- Two Quadlets: app + MariaDB (upstream constraint — first MariaDB
  in the homelab; rest are Postgres).
- machine_credential for mariadb root + user + bootstrap admin.
- No native OIDC; local accounts.

Actual Budget (money.findorff.gierz.eu):
- actualbudget/actual-server, YNAB-style envelope budgeting, SQLite
  per budget file. GoCardless bank import works for German banks.
- Native OIDC via ACTUAL_OPENID_* env vars (confirmed against
  actualbudget.org/docs/config/oauth-auth/). authentik_oidc_apps
  entry `actualbudget` declared; client_secret injected at start
  via render-actualbudget-secrets.sh from authentik's
  machine_credential path. Password login kept as fallback
  (ACTUAL_OPENID_ENFORCE=false).

Plumbing:
- cloudflare_dns: fit + garden + money subdomains.
- Caddy edge: TLS-terminate for all three.
- Caddy gateway: http:// upstreams to 192.168.122.30:{8090,8091,8092}.
- unified_portal: tiles for all three.
- services.yml: new "Family quality-of-life" section.
- gierz-services restic: backup paths for all three (DBs + media).

Bootstrap manual steps post-converge:
- Workout-Tracker: visit fit.findorff.gierz.eu, register first user
  (becomes admin), then ENABLE the registration_disabled flip in
  workout-tracker.env if you want others to self-register.
- HortusFox: visit garden.findorff.gierz.eu, log in as
  paul@gierz.eu / admin-password from
  /etc/hortusfox/credentials/admin-password.
- Actual Budget: visit money.findorff.gierz.eu, click "Login with
  OpenID", Authentik handles. ACTUAL_USER_CREATION_MODE=login
  auto-provisions on first SSO landing.

Co-Authored-By: LLM <noreply@invalid>
Three more services on gierz-services VM. Combined ~600 MB RAM.

Paperless-ngx (docs.findorff.gierz.eu):
- Three Quadlets — app + Postgres + Redis/Valkey. OCR every piece of
  paper that comes through the door, tag, full-text search.
- OCR languages: deu+eng. Timezone Europe/Berlin.
- Tika + Gotenberg sidecars (for docx/xlsx OCR) intentionally
  deferred — minimal three-container deploy first; add later if
  non-PDF ingestion picks up.
- Native OIDC via django-allauth's openid_connect provider, declared
  in PAPERLESS_SOCIALACCOUNT_PROVIDERS (declarative, unlike Tandoor).
- machine_credential for DB / Redis / Django SECRET_KEY / bootstrap
  admin.
- restic backs up media (originals + archive) + data; thumbnails
  excluded as regenerable.

Grocy (pantry.findorff.gierz.eu):
- Single linuxserver Quadlet, SQLite, no sidecars.
- Subdomain purpose-named (`pantry.`) not brand-named — same pattern
  as gotosocial→social, synapse→matrix.
- Local accounts only; no native OIDC upstream (long-standing issue).
  family-internal scope — fine for now.

HomeBox (inventory.findorff.gierz.eu):
- Single Quadlet, SQLite, no sidecars. Active fork
  (sysadminsmedia/homebox); original hay-kot/homebox archived 2024.
- Local accounts only; registration disabled (admin invites only).
- HBOX_OPTIONS_CHECK_GITHUB_RELEASE=false — no upstream calls from a
  family inventory app.

Plumbing:
- cloudflare_dns: docs + pantry + inventory subdomains.
- Caddy edge + gateway vhosts for all three. 500 MB body limit on
  paperless (ad-hoc batch uploads); defaults for the others.
- authentik_oidc_apps: paperless slug only (others don't speak OIDC).
- unified_portal: tiles for all three.
- services.yml: extends "Family quality-of-life" section.
- gierz-services restic: backup paths + paperless thumbnail exclude.

Bootstrap manual steps post-converge:
- Paperless: visit docs.findorff.gierz.eu, click "Login with Authentik"
  (first OIDC login creates the user). Bootstrap admin from
  /etc/paperless/credentials/admin-password remains for emergencies.
- Grocy: visit pantry.findorff.gierz.eu, default admin/admin then
  change immediately. Install the Grocy app on phones (Barcode
  Buddy / Grocy app) for barcode scanning.
- HomeBox: visit inventory.findorff.gierz.eu, register first user
  (becomes admin since registration_disabled gates only NEW users
  after bootstrap).

Co-Authored-By: LLM <noreply@invalid>
All four would have surfaced on first converge or in silently-broken
alerts.

1. prometheus.yml template: indent(6) → indent(6, first=True) on the
   params + relabel_configs blocks. Jinja's indent default leaves the
   first line at column 0, breaking YAML structure. Prometheus would
   refuse to load.

2. blackbox scrape targets: inline the URL list instead of
   targets: "{{ observability_blackbox_probe_targets }}" — Jinja-
   in-quoted-string list expansion via Ansible auto-unsafe is
   fragile and sometimes returns the stringified list literal.
   One source of truth now lives in gierz-services host_vars; the
   defaults entry stays for documentation only.

3. ContainerCrashloop: increase(...) → changes(...) and target the
   {state="active"} gauge instead of {state="activating"}. increase()
   only works on counters; the systemd-state metric is a gauge that
   flaps between 0 and 1. changes() counts how many times the value
   flipped. Threshold raised to 6 (each restart = down→up = 2 changes,
   so >6 ≈ >3 restarts in 5m).

4. CaddyHigh5xx → CaddyHighErrorRate: caddy_http_requests_total has
   no `code` label (verified against Caddy v2 docs — labels are
   server/handler/method). Original query matched zero series.
   Rewritten as caddy_http_request_errors_total / caddy_http_requests_total
   ratio — the idiomatic Caddy way to detect upstream/connection
   failures.

Untouched but still real risks (lower priority — won't break
convergence, just may not fire as expected):
- synapse_federation_send_queue_pending_pdus — verify metric name
  by curling /_synapse/metrics after first converge.
- headscale_node_last_seen — same. Headscale's metric naming has
  shifted across versions.

Co-Authored-By: LLM <noreply@invalid>
The family-QoL + paperless + jellyfin + hedgedoc + jupyter + overleaf
roll-out tips the existing 6 GiB allocation into OOM territory:
  - existing (authentik, synapse, mas, forgejo, vault, ntfy, …): ~3.5 GB
  - already-queued (immich, garage, tandoor, WT, hortusfox, AB,
    paperless, grocy, homebox): ~2.2 GB
  - new this sprint (jellyfin, hedgedoc, uptime-kuma, jupyter,
    overleaf): ~1.7 GB
  - total ≈ 7.5 GB before headroom. 6 GiB would OOM the heaviest
    container (likely Authentik or Overleaf); 10 GiB leaves real
    breathing room.

Live `tofu apply` against tofu/services requires a VM shutdown to
re-enumerate memory. Schedule for a quiet window or accept a few
minutes of downtime.

Future: if memory pressure shows up again, the right answer is more
VMs (a gierz-compute for jupyter+overleaf, a gierz-media for jellyfin)
rather than another bump.

Co-Authored-By: LLM <noreply@invalid>
Three more services on services VM. Combined ~500 MB RAM.

Jellyfin (media.findorff.gierz.eu):
- Single Quadlet, SQLite. Auto-creates /media/{movies,shows,music,
  audiobooks,books} subdirs; point Jellyfin libraries at these via
  the first-run wizard. Media tree NOT in restic (re-rippable, huge).
- Local accounts; per-user PINs work great for kids on the TV.
- WebSocket support automatic in the Caddy reverse_proxy.

Hedgedoc (notes.findorff.gierz.eu):
- App + Postgres on per-service network. Real-time collaborative
  markdown. Family + homelab runbook in one place.
- Native OIDC via CMD_OAUTH2_* env vars — Authentik's auth/token/
  userinfo URLs are shared across applications (only client_id
  differs), hardcoded in defaults.
- Email signup off; OIDC only. Anonymous edits ON for shareable
  notes.
- machine_credential for postgres password + session secret.

Uptime Kuma (status.findorff.gierz.eu):
- Single Quadlet, SQLite, no sidecars. 1.x stable (2.x still beta).
- Configure monitors via UI first-run; status page is a separate
  view it generates.

Plumbing:
- cloudflare_dns: media + notes + status (+ jupyter + tex DNS
  pre-staged for the next commit).
- Caddy edge + gateway vhosts for all five forward-looking entries.
- authentik_oidc_apps: hedgedoc slug.
- unified_portal: tiles for all (including jupyter + tex stubs).
- services.yml: extends QoL section with all five roles (jupyter +
  overleaf to land next).
- gierz-services restic paths + cache excludes.

Bootstrap manual:
- Jellyfin: visit media.…, run setup wizard, create admin, point
  libraries at /media/movies etc.
- Hedgedoc: visit notes.…, sign in with Authentik (first OIDC
  login creates the user).
- Uptime Kuma: visit status.…, register first user (admin), add
  monitors for each homelab endpoint.

Co-Authored-By: LLM <noreply@invalid>
JupyterLab (jupyter.findorff.gierz.eu):
- Single container, jupyter/scipy-notebook image (numpy/scipy/pandas/
  matplotlib/sklearn pre-installed). Single-user — full JupyterHub
  with DockerSpawner against the Podman socket is non-trivial; this
  is the pragmatic substitute. Swap to a jupyterhub role if/when
  multi-user matters.
- Token auth via machine_credential (access-token persists across
  restarts). Visit jupyter.…, paste token from
  /etc/jupyter/credentials/access-token on the VM.
- Work dir mount at /home/jovyan/work — survives image upgrades.

Overleaf CE (tex.findorff.gierz.eu):
- Three Quadlets: sharelatex (app) + mongo + redis/valkey. Mongo
  replica-set required since v5 even single-node — rs.initiate()
  is a one-time manual step (see role's post-install debug).
- CE limits: NO OIDC (Pro-only), no Git bridge, no track-changes.
  Local accounts. Sufficient for solo + spouse paper writing.
- Bootstrap admin via `grunt user:create-admin` after first start.
- TimeoutStartSec=600 — first start does apt-get + LaTeX-package
  install inside the image, takes minutes.

Resource caveats:
- Overleaf is the heaviest service in the stack (~1.5 GB RAM at
  rest, ~3 GB image). Bumped services-VM RAM to 10 GiB in prior
  commit anticipated this.
- Jupyter scipy-notebook image is ~1.5 GB. Pulls slow on first
  deploy.

Both wired into services.yml, Caddy (already-staged), DNS
(already-staged), unified_portal (already-staged), restic backup
paths (already-staged in inventory). Drop-in completion.

Bootstrap manual:
- Jupyter: visit jupyter.…, paste token. First notebook in
  /home/jovyan/work persists.
- Overleaf:
    1. `podman exec overleaf-mongo mongosh --eval 'rs.initiate(...)'`
    2. `systemctl restart overleaf`
    3. `podman exec overleaf grunt user:create-admin --email=paul@gierz.eu`
    4. Visit the printed one-time URL, set password, invite others.

Co-Authored-By: LLM <noreply@invalid>
NOT on a service VM — needs :53 reachable from 192.168.1.x family
devices, so runs on the hypervisor (192.168.1.54) with Network=host.
Bypasses podman netns so the DNS port binds on the LAN interface
directly.

Coexists with libvirt's internal dnsmasq (which keeps serving
192.168.122.0/24 to VMs) — different scope, no overlap.

Role:
- Single Quadlet, Network=host. Persistent work + conf dirs.
- Pre-flight `ss` check shows what's on :53 (informational —
  systemd-resolved on 127.0.0.53 is normal and won't conflict).
- Firewalld opens 53/tcp, 53/udp, 3000/tcp on the `public` zone
  (hypervisor's home-LAN-facing zone).

Plumbing:
- New playbooks/adguard.yml targets gierz-hypervisor.
- site.yml imports adguard.yml right after hypervisor.yml.
- cloudflare_dns: adguard.findorff A + AAAA.
- Caddy edge: TLS terminate. Caddy gateway: http:// upstream to
  192.168.1.54:3000 (gateway reaches LAN via libvirt NAT default
  route).
- unified_portal: tile.

First-run is wizard-driven — AdGuard generates its YAML config on
first visit. Manual steps in the role's post-install debug:
  1. Visit http://192.168.1.54:3000 on the LAN, run wizard.
  2. Upstream DNS: tls://1.1.1.1, tls://9.9.9.9 (DoT).
  3. Enable AdGuard DNS filter + EasyPrivacy + German list.
  4. Point home router DHCP at 192.168.1.54 — whole household uses
     AdGuard from then on.

DoT for incoming clients (port 853) deferred — needs a TLS cert
flow that doesn't conflict with Caddy on the hypervisor.

Co-Authored-By: LLM <noreply@invalid>
Pin a ~114G vfat USB stick read-only at /mnt/photo-stick on the hypervisor
(photo_stick role, no-op unless photo_stick_enabled), and mount it read-only
on gierz-laptop via a systemd --user sshfs service (sshfs_mounts role). Wire
both roles into hypervisor.yml / workstation.yml and set the host_vars.

Co-Authored-By: LLM <noreply@invalid>
0.6.3 requires integration.agent.pre_authkey, integration.kubernetes.pod_name,
and oidc.headscale_api_key_path even when the surrounding feature is disabled.
Provide empty-string placeholders and point headscale_api_key_path at the
materialised /run/headplane/headscale-api-key.

Co-Authored-By: LLM <noreply@invalid>
Add .terraform.lock.hcl for the two new VM modules (matching the existing
edge/gateway/services convention) and gitignore .terraform/ + terraform.tfstate*
so provider caches and secret-bearing state never get committed.

Co-Authored-By: LLM <noreply@invalid>
Add a single-command SMTP rollout: shared vars in group_vars/all/smtp.yml,
an email.yml orchestrator that flips mail_relay_enabled and applies each
service's --tags mail tasks, and per-service SMTP config blocks for forgejo,
authentik, vaultwarden, gotosocial, hedgedoc, tandoor, MAS, grafana and
alertmanager. Gated off by default so nothing sends until aliases + app
passwords are provisioned. (File-level commit; a few tag-only mail hunks in
nextcloud/synapse/mas/authentik task files ride along with their feature commits.)

Co-Authored-By: LLM <noreply@invalid>
gierz-cloud clones from a base volume and exposes VNC graphics; both modules
switch their libvirt provider URI to qemu+ssh and add VNC for console access.

Co-Authored-By: LLM <noreply@invalid>
Move Nextcloud + OnlyOffice onto gierz-cloud: html volume + redis password
fixes, idempotent secret rendering, url-safe machine_credential option for the
redis URL, NetworkManager zone-sync in services_vm_base to survive reboots, and
OIDC/onlyoffice integration + extra-apps wiring. cleanup_nextcloud_from_services.yml
removes the phantom install an earlier email.yml bug left on gierz-services.

Co-Authored-By: LLM <noreply@invalid>
oidc-app blueprint gains hidden_in_portal, an allowed-group policy binding, and
a slug->env hyphen fix; add the höring tenant bootstrap user blueprint. Resolve
auth.gierz.eu internally via unbound_local_fqdn_data + libvirt default-network
forwarder so the identity host works on the LAN. (gierz-gateway.yml also drops
the retired paul.gierz.eu landing site.)

Co-Authored-By: LLM <noreply@invalid>
Wire the höring.eu tenant: synapse metrics ports + MSC3861 issuer, MAS cross-host
OIDC secret fetch + python-cryptography dep, element native OIDC flow, the full
hoering-services identity block, and site_hoering orchestration. Add the mas.*
vhosts (edge MSC2965 .well-known + gateway) and cloudflare records (mas A/AAAA,
mailbox.org MX/SPF/DKIM/DMARC, ats prototype) with MX-priority support in the
cloudflare_dns role.

Co-Authored-By: LLM <noreply@invalid>
caddy gains a caddy_validate_debug no_log override for troubleshooting;
render-cloud-init skips hosts with empty-vault host keys; services.yml reloads
the podman network so aardvark-dns picks up new containers (2026-06 forgejo DNS
incident).

Co-Authored-By: LLM <noreply@invalid>
Co-Authored-By: LLM <noreply@invalid>
Add the mautrix_bridge role (Quadlet container + per-network configs for
whatsapp/signal/meta), playbooks/mautrix.yml standalone deploy, and Synapse
appservice token/registration rendering (render-appservice.yml +
appservice-registration template). gierz-services.yml gains the appservice
definitions, bridge admin MXID, mautrix_meta opt-in gate, and backup paths.
Meta/Messenger is off by default (account-suspension risk).

(Branch also carries the shared homelab WIP — email/nextcloud/höring/tofu —
that is already on main via separate commits; the merge dedupes it.)

Co-Authored-By: LLM <noreply@invalid>
# Conflicts:
#	inventory/group_vars/all/vault.yml
#	roles/mas/tasks/main.yml
#	roles/nextcloud/tasks/main.yml
# Conflicts:
#	inventory/host_vars/gierz-hypervisor.yml
#	inventory/host_vars/gierz-laptop.yml
#	playbooks/workstation.yml
Vendor the rootless Pi installer (Node 22 tarball + npm global into ~/.local,
pi-free for zero-API-key free models, seeded AGENTS.md/settings.json) from the
standalone pgierz.homelab collection into roles/pi, and add it to the workstation
play gated on pi_enabled (default true). One converge now also provisions the Pi
agent on workstations.

Co-Authored-By: LLM <noreply@invalid>
Stop reverse-proxying paul.gierz.eu to the gateway; serve it directly from
edge out of /var/www/paul.gierz.eu — /moin is the public landing (one link to
/work), /work is the academic site (Zola, base_url .../work), apex 308→/moin/.
Content is published by the standalone personal/paul-gierz-site Ansible package.

Co-Authored-By: LLM <noreply@invalid>
paul.gierz.eu is served statically from edge now (Caddyfile.gierz-edge.j2 +
personal/paul-gierz-site). Internal clients resolve it via public DNS → edge
(no unbound split-horizon entry), so the gateway block was unreachable from
either side. Applies on the next gateway converge.

Co-Authored-By: LLM <noreply@invalid>
Surgical, additive routing for the new familie.gierz.eu static site
(content lives in homelab/sites). No multi-site refactor.

- caddy(gateway): http://familie.gierz.eu serves /var/www/familie.gierz.eu
  (deployed by landing_page); add familie.gierz.eu to landing_sites.
- caddy(edge): familie.gierz.eu terminates TLS at edge and proxies plain
  HTTP over WG to the gateway. Resolves via the live *.gierz.eu wildcard,
  so no DNS change was needed.
- inventory: default gierz-gateway to the gierz-gateway-wg ssh alias
  (edge -> WireGuard -> gateway). Works on and off the home LAN since
  edge is public, so converges no longer need a hypervisor-LAN route or
  an -e override. Interim until CD runs from inside the network.

Deployed live 2026-06-28 (edge + gateway).

Signed-off-by: erwin <erwin@gierz.eu>
Co-Authored-By: LLM <noreply@invalid>
Claude-Session: https://claude.ai/code/session_015vW3t1ZgQvRCoKXQ5APTeP
paul force-pushed erwin/familie-routing from c6bee2a199 to 7a646fcb5c 2026-06-28 18:56:04 +00:00 Compare
Add a /photos/* path-proxy to the familie.gierz.eu block that forwards to
the Garage S3-website endpoint with the `familie-photos` bucket's vhost
Host header. Photos live in the Garage `familie-photos` website bucket
and are served at https://familie.gierz.eu/photos/<key> — same origin,
no new DNS/cert, no CORS. Uploading an object makes it appear with no
site rebuild; a missing object 404s and the page's onerror fallback
shows the placeholder frame.

Co-Authored-By: LLM <noreply@invalid>
Claude-Session: https://claude.ai/code/session_015vW3t1ZgQvRCoKXQ5APTeP
paul force-pushed erwin/familie-routing from 69521ca937 to 7a646fcb5c 2026-06-28 21:30:49 +00:00 Compare
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin erwin/familie-routing:erwin/familie-routing
git switch erwin/familie-routing

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff erwin/familie-routing
git switch erwin/familie-routing
git rebase main
git switch main
git merge --ff-only erwin/familie-routing
git switch erwin/familie-routing
git rebase main
git switch main
git merge --no-ff erwin/familie-routing
git switch main
git merge --squash erwin/familie-routing
git switch main
git merge --ff-only erwin/familie-routing
git switch main
git merge erwin/familie-routing
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
homelab/infra!1
No description provided.