Cloudflare DNS & CDN
All public traffic to this platform flows through Cloudflare. Cloudflare serves as the DNS provider, CDN, DDoS shield, and edge TLS terminator for every domain. DNS records and cache rules are managed entirely through OpenTofu.
What Cloudflare Provides
Section titled “What Cloudflare Provides”| Capability | How It’s Used |
|---|---|
| DNS | Authoritative nameserver for all three domain zones |
| CDN caching | Static assets cached at 300+ edge locations worldwide |
| DDoS protection | Always-on L3/L4/L7 mitigation on the free plan |
| TLS at the edge | Cloudflare-issued certificates presented to end users |
| Serve stale | Cached content served when the origin is down |
| IP masking | Origin server IP hidden behind Cloudflare proxy |
All of this runs on Cloudflare’s free plan — no paid features are required.
Traffic Flow
Section titled “Traffic Flow”graph LR
user["User Browser"]
cf["Cloudflare Edge<br/>(nearest PoP)"]
origin["Azure Public IP<br/>(node1)"]
traefik["Traefik<br/>(K3s)"]
user -->|"1. DNS lookup<br/>returns Cloudflare IP"| cf
cf -->|"2. TLS terminated<br/>at edge"| cf
cf -->|"3. Proxied to origin<br/>over HTTPS"| origin
origin --> traefik
- The user’s DNS lookup returns a Cloudflare anycast IP (not the origin server IP)
- Cloudflare terminates TLS using its own managed certificate
- If the response is cached, Cloudflare serves it directly from the edge — the request never reaches the origin
- For cache misses, Cloudflare proxies the request to the origin over HTTPS (Full SSL mode)
- Traefik receives the request and routes to the correct service
Domain Zones
Section titled “Domain Zones”Four separate Cloudflare zones are managed, each with its own zone ID:
| Zone | Domain | Subdomains | OpenTofu Reference |
|---|---|---|---|
| kevinryan.io | kevinryan.io | www, brand, docs, hq, analytics, monitoring, dam | module.cloudflare + root records |
| aiimmigrants.com | aiimmigrants.com | www | module.cloudflare_aiimmigrants |
| distributedequity.org | distributedequity.org | www | module.cloudflare_distributedequity |
| ai-native-engineer.io | ai-native-engineer.io | www | module.cloudflare_ainativeengineer |
DNS Records
Section titled “DNS Records”Per-Zone Records (Cloudflare Module)
Section titled “Per-Zone Records (Cloudflare Module)”The Cloudflare OpenTofu module creates DNS records for each domain zone. Every zone gets:
| Record | Type | Name | Content | Proxied |
|---|---|---|---|---|
| Root | A | @ | node1 public IP | Yes (orange cloud) |
| WWW | A | www | node1 public IP | Yes (orange cloud) |
| Subdomains | A | <subdomain> | node1 public IP | Yes (orange cloud) |
All records are proxied (orange cloud enabled), meaning:
- DNS lookups return Cloudflare’s anycast IP, not the origin
- Traffic is routed through Cloudflare’s network
- The origin server’s real IP is never exposed
- CDN caching and DDoS protection are active
The TTL is set to 1 (automatic) for all proxied records, as Cloudflare manages caching independently of DNS TTL.
kevinryan.io Zone Detail
Section titled “kevinryan.io Zone Detail”The kevinryan.io zone has the most records since it hosts both site subdomains and platform service subdomains:
| Record | Hostname | Managed By |
|---|---|---|
@ | kevinryan.io | Cloudflare module |
www | www.kevinryan.io | Cloudflare module |
brand | brand.kevinryan.io | Cloudflare module (subdomain list) |
docs | docs.kevinryan.io | Cloudflare module (subdomain list) |
analytics | analytics.kevinryan.io | Root module (standalone record) |
monitoring | monitoring.kevinryan.io | Root module (standalone record) |
The analytics and monitoring records are managed directly in the root OpenTofu module rather than via the Cloudflare module, since they are platform service subdomains rather than site subdomains:
resource "cloudflare_record" "analytics" { zone_id = var.cloudflare_zone_id name = "analytics" content = module.network.public_ip_address type = "A" proxied = true ttl = 1}
resource "cloudflare_record" "monitoring" { zone_id = var.cloudflare_zone_id name = "monitoring" content = module.network.public_ip_address type = "A" proxied = true ttl = 1}Complete DNS Map
Section titled “Complete DNS Map”Every hostname in the platform and what it resolves to:
| Hostname | Site/Service |
|---|---|
kevinryan.io | Portfolio |
www.kevinryan.io | Portfolio |
brand.kevinryan.io | Brand Guidelines |
docs.kevinryan.io | Documentation |
analytics.kevinryan.io | Umami Analytics |
monitoring.kevinryan.io | Grafana |
aiimmigrants.com | AI Immigrants |
www.aiimmigrants.com | AI Immigrants |
distributedequity.org | Distributed Equity |
www.distributedequity.org | Distributed Equity |
All 10 hostnames resolve to Cloudflare’s edge, which proxies to the same origin (node1’s public IP). Traefik handles host-based routing at the cluster level.
TLS Configuration
Section titled “TLS Configuration”SSL Mode: Full
Section titled “SSL Mode: Full”Cloudflare’s SSL/TLS mode is set to Full (configured in the Cloudflare dashboard). This means:
| Segment | Encryption | Certificate |
|---|---|---|
| User → Cloudflare | HTTPS | Cloudflare Universal SSL (trusted, auto-renewed) |
| Cloudflare → Origin | HTTPS | Traefik default cert (self-signed acceptable) |
graph LR
user["User"] -->|"Trusted TLS<br/>(Cloudflare cert)"| cf["Cloudflare"]
cf -->|"TLS<br/>(any cert accepted)"| origin["Traefik"]
origin -->|"Plain HTTP"| svc["Service Pod"]
Full mode (not Full Strict) is used because Traefik serves a self-signed certificate by default. Full mode encrypts traffic between Cloudflare and the origin but does not require a CA-signed certificate on the origin. This provides encryption in transit without the overhead of managing Let’s Encrypt certificates on the cluster.
Note: SSL mode is currently configured manually in the Cloudflare dashboard rather than via OpenTofu. The API token lacks
Zone Settings:Editpermission, and the relevant OpenTofu resource has known issues with read-only settings.
CDN Caching
Section titled “CDN Caching”Cache Rules
Section titled “Cache Rules”Each domain zone has an OpenTofu-managed cache ruleset that decides caching by path class, not by origin headers:
resource "cloudflare_ruleset" "cache" { zone_id = var.zone_id name = "Cache rules for ${var.domain}" description = "Cache immutable assets and serve stale on origin failure; bypass cache for HTML and dynamic app hosts" kind = "zone" phase = "http_request_cache_settings"
# dynamic app hosts (e.g. hq) — omitted entirely for zones without them # ... whole-host cache bypass ...
rules { action = "set_cache_settings" action_parameters { cache = true edge_ttl { mode = "override_origin" default = 86400 } serve_stale { disable_stale_while_updating = false } } expression = local.cache_expression description = "Cache immutable assets" enabled = true }
rules { action = "set_cache_settings" action_parameters { cache = false } expression = local.bypass_static_expression description = "Bypass cache for HTML and unhashed paths" enabled = true }}Three mutually exclusive rule classes:
| Rule | Matches | Setting | Effect |
|---|---|---|---|
| Dynamic app hosts | whole host (e.g. hq.kevinryan.io), every path | cache = false | Nothing on the host is edge-cached — LibreChat HTML/API/auth and overlay deploys are immediately visible |
| Immutable assets | static-site hosts AND asset paths (/_next/static/, /_astro/, or ends with .svg .png .ico .pdf .woff2 .woff .jpg .jpeg .webp) | cache = true, edge TTL 24h, serve stale | Content-addressed assets cached at the edge and served stale while the origin is down |
| HTML + unhashed paths | static-site hosts AND NOT asset paths | cache = false | HTML and unhashed assets (brand.css) never edge-cached — deploys are immediately visible |
The static-site rules’ expressions are mutually exclusive by construction (not <asset paths>), so rule order is irrelevant; the dynamic-host rule is host-disjoint from them.
Why path class, not origin headers
Section titled “Why path class, not origin headers”The ruleset originally used edge_ttl mode = "override_origin", default = 86400 on every path, which edge-cached HTML for up to 24 hours despite deploys — the KRA-17 root cause (observed on docs.kevinryan.io as cf-cache-status: HIT with hours-old content).
A naive “fix” of respect_origin does not work either: nginx never sends Cache-Control on browsed HTML paths — try_files $uri $uri.html $uri/index.html serves the file inside location /, which sets no header (only literal .html URIs hit the ~* \.html$ location and get no-cache). Verified on the live docs pod: / and /cloudflare/ return no Cache-Control at all. With respect_origin and no origin directive, Cloudflare caches HTML at its default ~2h edge TTL.
So the edge classifies requests by path instead:
- HTML and unhashed assets → never edge-cached; a deploy is visible the moment the new pod serves it
- Hashed/immutable assets → cached 24h at the edge, still served stale while the origin is unreachable
(Browsers still heuristically cache HTML that has no Cache-Control — fixing nginx’s headers is the separate follow-up item on unhashed/HTML asset headers.)
Cache Expressions
Section titled “Cache Expressions”Example for the kevinryan.io zone (static hosts exclude hq, the bypass subdomain):
# immutable assets rule — (static hosts) and (asset paths)(http.host eq "kevinryan.io" or http.host eq "www.kevinryan.io" or http.host eq "brand.kevinryan.io" or http.host eq "docs.kevinryan.io") and (starts_with(http.request.uri.path, "/_next/static/") or starts_with(http.request.uri.path, "/_astro/") or ends_with(http.request.uri.path, ".svg") or … )
# HTML + unhashed paths rule — (static hosts) and not (asset paths)(http.host eq "kevinryan.io" or … ) and not (starts_with(http.request.uri.path, "/_next/static/") or … )For zones without subdomains (e.g. aiimmigrants.com), the static-host list is just the root and www; zones without cache_bypass_subdomains (all but kevinryan.io) have no dynamic-host rule at all.
Serve Stale: Resilience Without Redundancy
Section titled “Serve Stale: Resilience Without Redundancy”The serve_stale setting is particularly important for this platform. With a single-origin architecture (one K3s cluster, one public IP), there is no automatic failover. If the origin goes down:
- Without serve stale: All requests fail immediately
- With serve stale: Cloudflare continues serving the last cached version of every cached asset (immutable CSS/JS/fonts/images) while the origin is unreachable
HTML is never edge-cached (deliberately — fresh deploys over edge-served HTML during outages, accepted with KRA-17), so serve stale applies to assets only; a page whose cached assets are on the edge still renders, but HTML requests fail while the origin is down. A manual zone purge (purge_everything via the Cloudflare API, CLOUDFLARE_API_TOKEN from .env.agents) remains an emergency tool for clearing bad cached assets.
DDoS Protection
Section titled “DDoS Protection”All proxied records benefit from Cloudflare’s DDoS mitigation:
- L3/L4 protection — volumetric and protocol attacks are absorbed by Cloudflare’s network before reaching the origin
- L7 protection — HTTP flood protection filters malicious requests at the application layer
- Always on — no configuration needed, included in the free plan
- IP masking — the origin IP is never exposed in DNS responses, preventing direct-to-origin attacks
This is especially relevant for a single-origin platform where the origin has no redundancy. Cloudflare acts as a shield that absorbs attack traffic at the edge.
Security Benefits
Section titled “Security Benefits”Beyond DDoS protection, proxying through Cloudflare provides:
| Benefit | How |
|---|---|
| Hidden origin IP | DNS returns Cloudflare IPs, not the Azure VM’s public IP |
| Bot mitigation | Basic bot management on the free plan filters known bad actors |
| Browser integrity check | Challenges requests with suspicious characteristics |
| Hotlink protection | Prevents other sites from embedding your assets |
OpenTofu Module
Section titled “OpenTofu Module”The Cloudflare module (infra/modules/cloudflare/) is called once per domain zone:
module "cloudflare" { source = "./modules/cloudflare" zone_id = var.cloudflare_zone_id vm_public_ip = module.network.public_ip_address domain = "kevinryan.io" subdomains = ["brand", "docs"]}
module "cloudflare_aiimmigrants" { source = "./modules/cloudflare" zone_id = var.cloudflare_zone_id_aiimmigrants vm_public_ip = module.network.public_ip_address domain = "aiimmigrants.com"}Each invocation creates:
- Root
Arecord (@) wwwArecord- One
Arecord per entry insubdomains - A cache ruleset covering all hostnames in the zone
Variables
Section titled “Variables”| Variable | Description |
|---|---|
zone_id | Cloudflare zone ID (one per domain) |
vm_public_ip | Origin server IP (node1’s Azure public IP) |
domain | Domain name (used in cache rule expressions) |
subdomains | List of additional subdomain records (default: []) |
Adding a New Domain
Section titled “Adding a New Domain”To add a new domain to the platform:
- Register the domain and set Cloudflare as the authoritative nameserver
- Add the zone ID as a new OpenTofu variable in
infra/variables.tf - Add a new Cloudflare module call in
infra/main.tf:
module "cloudflare_newsite" { source = "./modules/cloudflare" zone_id = var.cloudflare_zone_id_newsite vm_public_ip = module.network.public_ip_address domain = "newsite.com"}- Pass the zone ID value via
terraform.tfvarsor as a GitHub Actions secret - Run
tofu planandtofu apply(via the OpenTofu workflow)
The module handles root, www, and cache rule creation automatically. For additional subdomains, add them to the subdomains list.
Adding a Subdomain to an Existing Zone
Section titled “Adding a Subdomain to an Existing Zone”For subdomains on kevinryan.io (e.g. a new api.kevinryan.io):
- Site subdomains — add the subdomain to the
subdomainslist in the existingmodule.cloudflarecall - Service subdomains — add a standalone
cloudflare_recordresource in the root module (as done foranalyticsandmonitoring)