Caddy

Reverse proxy for *.ops.eblu.me services with automatic TLS via ACME DNS-01.

Quick Reference

PropertyValue
Domain*.ops.eblu.me
HTTPS Port443
Configansible/roles/caddy/templates/Caddyfile.j2
Binarynixpkgs caddy.withPlugins (Gandi DNS + L4) in the generation; the role-rendered wrapper execs it via /nix/var/nix/profiles/system/sw/bin/caddy
Unitnix-managed LaunchAgent mcquack.eblume.caddy (indri flake)

Why Caddy?

Caddy provides a single TLS termination point for all BlumeOps services:

  • Wildcard certificate for *.ops.eblu.me via Let’s Encrypt
  • DNS-01 challenge using Gandi API (no port 80 needed)
  • Unified access from k8s pods, containers, and tailnet clients

See routing for when to use *.ops.eblu.me vs *.tail8d86e.ts.net.

Proxied Services

Indri-Local Services

SubdomainBackendService
forge.ops.eblu.melocalhost:3001forgejo
registry.ops.eblu.melocalhost:5050zot
jellyfin.ops.eblu.melocalhost:8096jellyfin

Kubernetes Services

K8s services are proxied via their Tailscale Ingress endpoints:

SubdomainBackendService
grafana.ops.eblu.megrafana.tail8d86e.ts.netgrafana
argocd.ops.eblu.meargocd.tail8d86e.ts.netargocd
authentik.ops.eblu.meauthentik.tail8d86e.ts.netauthentik
photos.ops.eblu.mephotos.tail8d86e.ts.netimmich
paperless.ops.eblu.mepaperless.tail8d86e.ts.netpaperless
feed.ops.eblu.mefeed.tail8d86e.ts.netminiflux
heph.ops.eblu.melocalhost:8787hephaestus hub
……(see defaults/main.yml for the full ~25-service list)

Statically-Served Sites

Some sites are served directly by Caddy from disk (kind: static, file_server) rather than proxied to a backend:

SubdomainRootSite
docs.ops.eblu.me{{ docs_content_dir }}docs (Quartz; also public at docs.eblu.me via flyio-proxy)
cv.ops.eblu.me{{ cv_content_dir }}cv (serves resume.pdf as an attachment download)

Fly Proxy Listener (:8443)

A second listener, *.ops.eblu.me:8443, serves only the services flagged fly_proxied: true (docs, cv, photos) — the flyio-proxy path. New services are unreachable from the fly node by default: to expose one, add fly_proxied: true to its caddy_services entry and its name to caddy_fly_proxied_services — mise run caddy-render-check fails if the two diverge.

TCP Services (Layer 4)

PortBackendService
2222localhost:2200Forgejo SSH
5433immich-pg.tail8d86e.ts.net:5432postgresql (immich-pg on ringtail)
5434blumeops-pg-ringtail.tail8d86e.ts.net:5432postgresql (blumeops-pg on ringtail)
(exporters)sifaka:*Sifaka node_exporter + smartctl_exporter

Port 5432 → pg.tail8d86e.ts.net (minikube blumeops-pg) was retired with the cluster — see retire-minikube.

Configuration

The Caddyfile, wrapper script and Gandi token file are managed via the caddy Ansible role; the LaunchAgent unit is nix-managed (launchd.user.agents."mcquack.eblume.caddy" in the indri flake, PR 6 of the nix-darwin series), so config changes apply with the role tag while the plist flip applies with --tags rebuild. The role’s plist + load tasks are skipped by default (caddy_ansible_managed: false) and remain the rollback re-write — see provision §Rolling back a service flip:

# Deploy caddy config changes (Caddyfile, wrapper, token file)
mise run provision-indri -- --tags caddy

Key files:

  • ansible/roles/caddy/defaults/main.yml - Service definitions
  • ansible/roles/caddy/templates/Caddyfile.j2 - Caddy config template
  • darwin/indri/configuration.nix - LaunchAgent unit

Secrets

SecretSourceDescription
GANDI_BEARER_TOKEN1PasswordAPI token for DNS-01 challenges

The token is written to ~/.config/caddy/gandi-token (chmod 0600) and sourced by the Caddy wrapper script.

Security Considerations

Caddy has no authentication layer — it is a plain reverse proxy. Access control relies entirely on Tailscale ACLs restricting which devices can reach indri on port 443. Indri:443 is reached by tag:homelab/autogroup:admin tailnet clients as before. The fly node reaches Caddy only via the fly-only :8443 listener (tag:flyio-origin, tcp:8443 only) — it has no path to indri:443 anymore (eblume/blumeops#1396).

The flyio-proxy routes all public traffic through Caddy. This is the path for *.eblu.me requests from the public internet. Caddy sees these as requests from the Fly VM with Host: *.ops.eblu.me headers — the same routes used by tailnet clients.

Binary Lineage

The binary is a nixpkgs caddy.withPlugins build (Gandi DNS for ACME DNS-01 and L4 for the TCP routes), declared in the indri flake’s environment.systemPackages so it is in the generation’s closure. The role-rendered wrapper execs it through the system profile’s /nix/var/nix/profiles/system/sw/bin/caddy — the profile path, so it follows every switch and --rollback — and the LaunchAgent plist’s ProgramArguments[0] stays the on-disk wrapper (a store path there would die EX_CONFIG in the pre-/nix boot window). The prior xcaddy checkout build at ~/code/3rd/caddy was retired with the flip (blumeops#1275); the checkout and its forge mirrors (mirrors/caddy, mirrors/caddy-gandi, mirrors/xcaddy, mirrors/caddy-l4) are deleted, so a lineage rollback is darwin-rebuild --rollback alone.

  • gandi - DNS hosting and ACME DNS-01 provider
  • routing - Service routing architecture
  • forgejo - Git forge (proxied by Caddy)
  • zot - Container registry (proxied by Caddy)
  • tailscale-operator - K8s services use Tailscale Ingress, then Caddy