Skip to content

Cloudflare

Cloudflare is authoritative DNS for tomoda.life, and the edge for the prod app. The prod app hostnames (api, app) are proxied (WAF, DDoS, edge TLS, hidden origin, mTLS origin lock); everything else (assets, -dev, www) is DNS-only straight to origin (CloudFront for assets, the GCP LB / Traefik for the app). See Edge posture.

Cloudflare DNS
↙   ↘
api / app (proxied)
Cloudflare edge WAF, DDoS, mTLS
GCP LB → Traefik
assets / -dev / www (DNS-only)
CloudFront
GCP LB → Traefik
Proxied prod app runs through the edge; everything else resolves straight to origin.

Records managed by Terraform

infrastructure/aws/cloudflare.tf defines exactly two record types in the Cloudflare zone, both per-environment via Terraform workspaces.

1. ACM validation CNAMEs

When aws_acm_certificate.cert is created (see ACM), ACM emits a randomised CNAME that the domain owner must publish to prove control. Terraform reads domain_validation_options off the ACM resource and writes the corresponding record into Cloudflare:

resource "cloudflare_dns_record" "acm_validation" {
  for_each = {
    for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = var.cloudflare_zone_id
  name    = each.value.name
  content = each.value.record
  type    = each.value.type
  proxied = false
  ttl     = 60
}

proxied = false is required — ACM does not resolve names through Cloudflare's edge, so a proxied (orange-cloud) record would silently fail validation.

2. Asset CNAME

The user-facing asset domain CNAMEs straight to the CloudFront distribution:

resource "cloudflare_dns_record" "assets_cname" {
  zone_id = var.cloudflare_zone_id
  name    = local.assets_subdomain                # "assets" (prod) or "assets-dev" (dev)
  content = aws_cloudfront_distribution.s3_distribution.domain_name
  type    = "CNAME"
  proxied = false
  ttl     = 300
}

This is the only DNS edge between a browser and CloudFront. Because proxied = false, the client opens the TCP connection directly to a CloudFront IP — Cloudflare's role ends as soon as the A/AAAA lookup completes.

Records managed by external-dns (not Terraform)

The application-surface records — api.tomoda.life, app.tomoda.life, www.tomoda.life, and the matching -dev subdomains — are dynamically managed by external-dns running on the cluster, not by this Terraform root. See External-DNS for the configuration: the controller watches Kubernetes Ingress and Service resources, filters on domainFilters: [tomoda.life], and writes/updates Cloudflare records to match what's deployed. TXT-record ownership is tagged with owner: k8s-dev so multiple controllers can coexist safely.

The Cloudflare API token used by Terraform here is the same token external-dns uses (external-dns-cloudflare-secret in the cluster). Token scope: Zone:DNS:Edit on tomoda.life.

Do not edit application records by hand

Editing api.*, app.*, or www.* records in the Cloudflare console will be silently reverted on the next external-dns sync loop (default: every minute). To change a hostname, update the Ingress in k8s/apps/tomoda/overlays/<env>/ and let external-dns reconcile.

Edge posture: prod app proxied + origin-locked, everything else DNS-only

  • Proxied (apex tomoda.life, api, app, www): edge WAF, DDoS, TLS, hidden origin, and the edge is where the OG worker and the app/www to apex redirects run. Set via external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" on the prod Ingress.
  • DNS-only (assets): direct to origin.

Origin lock — mTLS (Authenticated Origin Pulls), not an IP allowlist. The Traefik LB is shared with grey-cloud dev, so a loadBalancerSourceRanges allowlist would block dev. Instead:

  • Cloudflare presents its origin-pull client cert on proxied requests (zone tls_client_auth, infrastructure/cloudflare/origin_pulls.tf).
  • Traefik requires + verifies it for api/app only, via a TLSOption (clientAuth: RequireAndVerifyClientCert against Cloudflare's origin-pull CA) referenced from the prod Ingress (k8s/apps/tomoda/overlays/prod/cloudflare-aop.yaml).

A request that bypassed the edge has no valid cert and is rejected at TLS. Unspoofable, and it defeats the shared-Cloudflare-IP bypass an allowlist can't.

AOP ordering

Cloudflare must present the cert before Traefik requires it, or every prod request fails TLS. At prod launch, enable zone AOP and confirm it is on before the Traefik TLSOption syncs.

Assets stay on CloudFront (no double-CDN with different invalidation models).

Shared tomoda.life/<share> links must unfurl a preview card in iMessage / Slack / WhatsApp, but the app is a JS SPA that crawlers cannot run. A Cloudflare Worker (og-preview/worker.js, deployed via infrastructure/cloudflare/og_worker.tf) splits traffic at the edge:

  • Crawler (User-Agent match) on a content path → fetches the server-rendered OG HTML from the backend (/api/v1/og/<path>) and returns it.
  • Human → the real web app: pass-through, since the worker runs on the app host.
  • /img/* → transparent proxy to the backend image endpoint, which re-applies the entity's visibility gate and 302s to a freshly signed asset (a gated image is never served).
  • /.well-known/apple-app-site-association + assetlinks.json → served inline at 200 so iOS/Android universal links verify.

Routes (cloudflare_workers_route): tomoda.life/* (prod, backend api.tomoda.life) and app-dev.tomoda.life/* (dev, api-dev.tomoda.life); the worker self-selects origins from the request host. Worker routes only fire on proxied hostnames, so the share host is the canonical host (the apex, proxied via external-dns; dev is proxied through the base Ingress annotation).

The apex is canonical: app.tomoda.life and www.tomoda.life 301 to it at the edge (redirects.tf), so a share link never sits on a redirecting host. The share host is chosen app-side by Public.BaseURL (backend) and EXPO_PUBLIC_WEB_URL (frontend): tomoda.life in prod, app-dev.tomoda.life in dev.

Operational notes

  • The Terraform-managed token is read at apply time from a Kubernetes secret (external-dns-cloudflare-secret). It must have at minimum Zone:DNS:Edit scope on the tomoda.life zone.
  • DNS changes propagate in well under a minute given the 60–300 second TTLs in use.
  • Re-running terraform apply after a workspace switch is the supported way to update records; do not edit Terraform-managed records by hand in the Cloudflare console, as the next apply will revert them.