## Summary - Adds `docs/how-to/expose-service-publicly.md` documenting the full plan for exposing `docs.eblu.me` to the public internet - Covers Cloudflare Tunnel + CDN architecture, DNS migration from Gandi, Caddy TLS changes, Pulumi IaC, k8s cloudflared deployment, and verification steps - Pattern is reusable for future public services - Marked as "Plan — not yet implemented" status ## Test plan - [x] `docs-check-links` passes - [x] `docs-check-index` passes - [x] All pre-commit hooks pass Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/118
7.9 KiB
| title | tags | |||
|---|---|---|---|---|
| Expose a Service Publicly |
|
Expose a Service Publicly via Cloudflare Tunnel
Status: Plan — not yet implemented. Execute phases in order when ready.
This guide describes how to expose a BlumeOps service to the public internet securely using Cloudflare as a CDN and DDoS shield, with a Cloudflare Tunnel creating an outbound-only connection that never exposes the home IP.
The first service to expose is docs.eblu.me. The pattern is reusable for future services.
Architecture
Internet → docs.eblu.me (Cloudflare proxied CNAME)
│
Cloudflare Edge (CDN, WAF, DDoS protection)
│
Cloudflare Tunnel (outbound from k8s)
│
cloudflared pod in minikube
│
docs k8s Service (ClusterIP, port 80)
│
docs pod (nginx + Quartz static site)
Tailnet → *.ops.eblu.me (unchanged, DNS-only to Tailscale IP)
All existing *.ops.eblu.me services remain private behind Tailscale. Only explicitly configured subdomains (like docs.eblu.me) are exposed publicly through Cloudflare.
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| DNS hosting | Move from gandi to Cloudflare (free) | CNAME/partial setup needs Business plan @ $200/mo |
| Gandi role | Registrar only | Domain renewal, WHOIS. No more DNS hosting. |
| Tunnel host | Kubernetes | ArgoCD managed, direct ClusterIP access, no Tailscale hop |
| caddy TLS | Migrate to Cloudflare DNS-01 plugin | Gandi DNS-01 won't work after nameserver change |
| Cloudflare account | Recover existing, instrument with IaC |
Prerequisites
- Cloudflare account with
eblu.mezone added (free plan) - Cloudflare API token stored in 1Password with scopes: Zone:DNS:Edit, Zone:Zone:Read, Account:Cloudflare Tunnel:Edit, Account:Account Settings:Read
- Cloudflare account ID and zone ID noted
Phase 0: Preparation (manual)
- Recover Cloudflare account access
- Add
eblu.mezone (free plan) — Cloudflare scans existing records from Gandi - Do not change nameservers yet — wait until Phase 3
- Create API token with the scopes listed above
- Store API token and account ID in 1Password (blumeops vault)
Phase 1: Caddy TLS migration
Why first: Blocking dependency for the nameserver change. Once nameservers move to Cloudflare, Gandi LiveDNS can't serve DNS-01 ACME challenges.
Caddy binary rebuild
Rebuild Caddy with github.com/caddy-dns/cloudflare instead of github.com/caddy-dns/gandi using xcaddy in ~/code/3rd/caddy/.
Files to modify
ansible/roles/caddy/templates/Caddyfile.j2— changedns gandi {env.GANDI_BEARER_TOKEN}todns cloudflare {env.CF_API_TOKEN}ansible/roles/caddy/templates/caddy-wrapper.sh.j2— source Cloudflare API token instead of Gandi PATansible/roles/caddy/defaults/main.yml— update token variable nameansible/playbooks/indri.yml— add pre_task to fetch Cloudflare API token from 1Password, replace Gandi PAT fetch
Deployment sequence
- Set up Cloudflare zone with all records (Phase 2)
- Prepare Caddy migration on a branch (this phase)
- Change nameservers at Gandi (Phase 3)
- Immediately deploy Caddy update:
mise run provision-indri -- --tags caddy - Caddy's next TLS renewal uses Cloudflare DNS-01
Existing certificates are valid for ~90 days, providing a grace window.
Phase 2: Pulumi — Cloudflare IaC
Create a new Pulumi project at pulumi/cloudflare/.
Files to create
pulumi/cloudflare/Pulumi.yaml— project definition (blumeops-cloudflare, python/uv)pulumi/cloudflare/Pulumi.eblu-me.yaml— stack config (domain, account-id)pulumi/cloudflare/pyproject.toml— deps:pulumi>=3.0.0,pulumi-cloudflare>=5.0.0pulumi/cloudflare/__main__.py
Pulumi program manages
- Zone lookup for
eblu.me - DNS records:
*.ops.eblu.meA record → Tailscale IP, proxied=False (grey cloud, private)ops.eblu.meA record → Tailscale IP, proxied=Falsedocs.eblu.meCNAME →<tunnel-id>.cfargotunnel.com, proxied=True (orange cloud, CDN)
- Cloudflare Tunnel resource
- Tunnel config (ingress:
docs.eblu.me→http://docs.docs.svc.cluster.local:80) - Cache rules for static docs site (edge TTL: 1 day, browser TTL: 1 hour)
- Zone security settings (SSL: full, min TLS 1.2, always HTTPS)
New mise tasks
Following the dns-preview/dns-up pattern:
mise-tasks/cloudflare-preview—pulumi previewwith 1Password token injectionmise-tasks/cloudflare-up—pulumi upwith 1Password token injection
Keep pulumi/gandi/ until migration is confirmed working. Then pulumi destroy the Gandi stack and archive the code.
Phase 3: DNS migration
Pre-migration checklist
- Cloudflare zone active with all records (Phase 2)
- Caddy migration branch ready (Phase 1)
- Cloudflare Tunnel created and configured (Phase 2)
- cloudflared running in k8s (Phase 4)
Steps
- At Gandi registrar dashboard: change nameservers to Cloudflare's assigned NS
- Deploy Caddy update immediately:
mise run provision-indri -- --tags caddy - Monitor propagation:
dig +trace docs.eblu.me,dig +trace forge.ops.eblu.me - Verify tailnet services still work from tailnet clients
- Verify
docs.eblu.meresolves publicly
Rollback
Change nameservers back to Gandi's at registrar. Everything reverts.
Phase 4: cloudflared in Kubernetes
Files to create
argocd/apps/cloudflare-tunnel.yaml— ArgoCD Applicationargocd/manifests/cloudflare-tunnel/deployment.yaml— cloudflared Deployment- Image:
cloudflare/cloudflared:latest(or pinned version) - Args:
tunnel --no-autoupdate run --token <tunnel-token> - Single replica, tunnel token injected from a Secret
- Image:
argocd/manifests/cloudflare-tunnel/external-secret.yaml— ExternalSecret to pull tunnel token from 1Passwordargocd/manifests/cloudflare-tunnel/kustomization.yaml
Tunnel routing (managed by Pulumi)
docs.eblu.me→http://docs.docs.svc.cluster.local:80(direct k8s service access)- Catch-all →
http_status:404
Namespace: cloudflare-tunnel (dedicated, reusable for future public services)
Phase 5: Documentation and cleanup
Files to create
docs/reference/infrastructure/cloudflare.md— reference carddocs/changelog.d/<branch>.feature.md— changelog fragment
Files to modify
docs/reference/infrastructure/routing.md— add public services sectiondocs/reference/infrastructure/gandi.md— update to registrar-only roledocs/reference/services/docs.md— add public URLhttps://docs.eblu.medocs/reference/reference.md— add Cloudflare to infrastructure sectionCLAUDE.md— update routing table, add cloudflare tasks
Verification
curl -I https://docs.eblu.mefrom public internet — returns 200 withcf-rayheaderdig docs.eblu.me— shows Cloudflare IPs (not Tailscale IP)dig forge.ops.eblu.me— still shows100.98.163.89(Tailscale IP)- All
*.ops.eblu.meservices accessible from tailnet mise run services-checkpasses- Caddy TLS renewal works (force test with
caddy reloadif needed) - Cloudflare dashboard shows tunnel healthy and cache hits
Risks
| Risk | Mitigation |
|---|---|
| Caddy TLS renewal fails after NS change | Deploy Caddy update immediately; existing certs valid ~90 days |
| DNS propagation delay (24-48h) | Set low TTLs before migration; monitor with dig +trace |
| cloudflared crashes | K8s restarts it; Cloudflare serves cached content |
| Tunnel credentials leak | 1Password + ExternalSecret; tunnel only routes to docs |
Adding more public services
To expose another service publicly (e.g., wiki.eblu.me):
- Add DNS record + tunnel ingress rule in
pulumi/cloudflare/__main__.py - Run
mise run cloudflare-up - No changes to cloudflared deployment (remotely-managed tunnel config)