Cloudflare in front of the EKS demo (optional Phase 8)
The existing domain eksgitops.click moves behind Cloudflare: DNS, edge TLS, DDoS protection and the Free plan's managed rules, Tunnel and Access with Keycloak as the identity provider. Design, mental models and migration runbook. Prepared 2026-10-16; updated 2026-10-17 to what infra/cloudflare builds (real Service names, the Access policy, the flip mechanism).
In one paragraph: Cloudflare is the last phase, done only after every feature works, and on the same domain. It's a full switch, kept simple:
- DNS: the domain's name servers move to Cloudflare.
- Every hostname (
app.,auth.,argocd.,grafana.) goes through Cloudflare's proxy (TLS, DDoS protection, the Free Managed Ruleset, and room for 5 custom rules and 1 rate-limiting rule on the Free plan) and a Tunnel that the cluster dials outward, to plain ClusterIP Services. - Access (login delegated to Keycloak) gates only
argocd.andgrafana.. - Retired on 2026-10-18, after a one-day fallback window: the ALB, ACM-on-ALB, the load balancer controller, and external-dns with its Route 53 records and hosted zone, so the Tunnel is now the only ingress (app #51 and the pull requests that follow it, gitops #6 onwards), and the cutover runbook below, with its
host_modesflips and Route 53 mirror, is the record of how it got there. - Code changes: only the client-IP header the apps trust. Keycloak and the GraphQL API otherwise stay unchanged.
Colour key:
1. Decision
| Same domain, full setup (chosen) | Second domain only for Cloudflare (considered) | |
|---|---|---|
| Realism | How organisations onboard: their existing domain moves behind Cloudflare | A split that real setups rarely use |
| Public app protection | Every host, including app./auth., gets the proxy (DDoS protection, the Free Managed Ruleset, Free-plan custom and rate-limiting rules) | Only the admin tools get Cloudflare |
| Things to run | One DNS provider, one record set, one domain in redirect URIs and SSO | Two of each |
| Risk to the working setup | A name-server switch with a propagation window (hours, up to 24–48 h) | None |
| Cost | US$0 (Cloudflare Free + Zero Trust Free) | +US$3/year |
| Full switch: every host via Tunnel (chosen) | Hybrid: app./auth. on ALB, admin via Tunnel (alternative / interim) | |
|---|---|---|
| Moving parts | One ingress path; the ALB, LB-controller Ingresses, ACM-on-ALB and external-dns are retired | Two ingress paths to run and explain |
| Exposure | Nothing in AWS accepts inbound traffic | A public ALB remains (must be locked to Cloudflare ranges if proxied) |
| Client IP | Apps trust CF-Connecting-IP from cloudflared only; a NetworkPolicy makes it unspoofable | ALB plus proxy header handling |
| Cost | About US$20/month saved (no ALB) | ALB stays |
| When | Target state | Exists only during the fallback window of the rollout |
Timing rule: run the cutover only when all features are stable, and at least several days before any demo, so propagation and rollback never overlap a demo.
2. Today vs target
Cluster-side changes:
- Added: a
cloudflaredDeployment and a NetworkPolicy. - Retired (after the fallback window): the Ingresses, so the ALB disappears.
- Changed in the apps: only which client-IP header they trust.
3. Mental models
Client → edge → connector → origin
| Question | Answer |
|---|---|
| What does "edge" look like? | dig +short app.eksgitops.click returns Cloudflare anycast IPs, not AWS ones. Responses carry server: cloudflare and cf-ray: …-SYD (request id + the data centre that served it). There's no separate URL: your own hostnames resolve to Cloudflare. |
| How does the tunnel work? | cloudflared opens outbound connections (port 7844) to nearby data centres. The edge sends requests back down those connections. Nothing listens inbound: no ALB, NodePort or public IP is needed for tunnelled hosts. |
| Do I still need Kubernetes Services? | Yes: plain ClusterIP. They give cloudflared a stable name and spread traffic across pods. A NetworkPolicy lets only the cloudflared namespace reach the tunnelled apps. |
| Certificates? | Cloudflare's free Universal SSL covers eksgitops.click + *.eksgitops.click at the edge. The ACM certificate matters only while the ALB is kept as a fallback. |
| Proxied vs DNS-only? | Per DNS record. Proxied (orange cloud): traffic goes through the edge and gets DDoS protection and the managed rules. DNS-only (grey cloud): Cloudflare just answers DNS and the browser connects to the target directly. In the target state all four hosts are proxied tunnel CNAMEs. DNS-only exists only for the fallback records. |
| Real client IP? | Every request reaches the apps from cloudflared. Apps trust CF-Connecting-IP only when the peer is cloudflared. A NetworkPolicy guarantees nothing else can reach them, so the header can't be spoofed. The rate limiter and Keycloak's brute-force protection key on it. This is switched on only after the ALB is retired: while the ALB can still reach the pods, trusting the header would let its clients forge it. |
4. Identity and tokens
| Role | Keycloak | Cloudflare Access |
|---|---|---|
| What it is | Identity provider, OAuth 2.0 authorization server and OpenID Provider (one realm = one issuer) | An identity-aware gate in front of chosen hostnames |
| Stores users, passwords, MFA | ✅ | ❌ It delegates login to an IdP: Keycloak here, Entra ID in many enterprises (OIDC) |
| Issues | ID, access and refresh tokens | Its own short-lived session JWT, cookie CF_Authorization |
| Decides access by | Roles and claims inside the access token, enforced by the API | Policies per application: Include (OR), Require (AND), Exclude (NOT) on groups, email, country, device posture, MFA; actions Allow / Block / Bypass / Service Auth |
| Machines | Client credentials (service accounts) | Service tokens (two headers) at the edge |
Who checks which token
| Token | Issued by | Checked by | Analogy |
|---|---|---|---|
CF_Authorization cookie | Cloudflare | The edge, on every request; denied requests never reach the cluster | Building pass at the front door |
Cf-Access-Jwt-Assertion header | Cloudflare (copy added when forwarding) | Optional: cloudflared's access origin check, or an app that wants "who is this" | A note saying "checked at the door" |
Access token (Authorization: Bearer) | Keycloak | The GraphQL API: signature, iss, aud=demo-api, exp, roles | Bank card used at the counter |
| ID token | Keycloak | The client only ("logged in as …") | Name badge |
| Refresh token | Keycloak | Keycloak only (rotated on use) | Card renewal slip |
Login flow: one password, several gates
- Front channel vs back channel: redirects travel through the browser; the code-for-token exchange is a direct server call from Cloudflare to Keycloak's
/tokenendpoint. Both needauth.eksgitops.clickreachable: through the tunnel, proxied but not gated by Access. - The SPA is unaffected: the SPA's own PKCE flow gets its API token the same silent way, reusing the Keycloak session cookie, so the user types one password.
- Never put Keycloak's login pages behind Access: that's where the Access login itself happens, so it would loop. Only Keycloak's admin console stays hidden (it's already not routed publicly).
- What Keycloak needs: no code.
KC_HOSTNAMEandKC_PROXY_HEADERS=xforwarded, already needed behind any proxy.cloudflaredsetsX-Forwarded-For/-Proto, so brute-force detection sees the real client.- One confidential client
cloudflare-accessinrealm.yaml(standard flow, redirecthttps://eksgitops.cloudflareaccess.com/cdn-cgi/access/callback, theemailscope), only because Access uses Keycloak as its IdP. Its secret is generated by Terraform and read from Secrets Managerdemo/keycloak/cloudflare-access(JSONclient_id,client_secret). - The GraphQL API changes only its trusted client-IP header.
5. Full vs partial vs subdomain setup
| Setup | How | Plan | For us |
|---|---|---|---|
| Full | The domain's name servers point at Cloudflare; Cloudflare answers DNS for every name | Free and up | ✅ Chosen: it's the only option on Free |
| Partial (CNAME) | DNS stays at Route 53; chosen names are CNAMEd to Cloudflare | Business and up | ❌ Paid |
| Subdomain | Delegate only ops.eksgitops.click to Cloudflare | Enterprise | ❌ Paid |
That's why the whole zone moves: every existing record must be recreated in Cloudflare before the name servers switch.
6. Prerequisites (one-time; done 2026-10-17)
- Cloudflare account (free) at
dash.cloudflare.com, email verified, 2FA on. The Account ID (not secret) is theaccount_idvariable ofinfra/cloudflare. - The zone is not added in the dashboard: Terraform creates
eksgitops.click(Free plan, full setup, no record scan) and every record. - Zero Trust organization created once through the API (
POST /accounts/<id>/access/organizations): teameksgitops, soeksgitops.cloudflareaccess.com. Free plan (up to 50 users). Terraform leaves it unmanaged: nothing in it differs from the defaults. - A custom API token with: Account · Cloudflare Tunnel · Edit; Account · Access: Apps and Policies · Edit; Account · Access: Organizations, Identity Providers, and Groups · Edit; Account · Account Settings · Edit; Zone · Zone · Edit; Zone · Zone Settings · Edit; Zone · DNS · Edit (all zones; zone creation needs Zone · Edit). It expires 2026-12-17. Narrower would be an account-owned token scoped to the one zone.
- Store the token from a separate terminal, never in chat or Git:
aws secretsmanager create-secret --region ap-southeast-2 --name demo/cloudflare/api-token --secret-string '…'. Onlytf-applyonmainreads it (masked); pull requests never do.
7. Migration runbook
you manual · Terraform infra/cloudflare · Argo gitops repo · CLI one-off command. Every step has a check.
A. Prepare (no user impact; one merge of infra/cloudflare)
- Terraform Zone (pending until the switch) and its settings: SSL Full (strict), minimum TLS 1.2, Always Use HTTPS. Check: the
tf-applysummary showsinfra/cloudflareapplied; the next plan shows no changes. - Terraform Mirror today's DNS in Cloudflare, so the name-server switch changes nothing:
app./auth./argocd./grafana.→ DNS-only CNAMEs to the ALB, TTL 60 s. The ALB is found by the load balancer controller's tags (elbv2.k8s.aws/cluster,ingress.k8s.aws/stack=demo), so a rebuilt cluster needs no edit;- every other Route 53 record, read live and copied as-is: the ACM validation CNAME (kept for the fallback window) and external-dns's ownership TXT records. The apex NS/SOA are Cloudflare's own. The plan fails rather than drop a record it can't copy (an alias or an unusual type).
dig @<cf-ns> app.eksgitops.clickreturns a CNAME to the ALB name that Route 53's alias resolves to; the ACM CNAME is identical. - Terraform DNS ownership: Terraform-managed records (four static hosts,
host_modes). external-dns keeps writing Route 53 until the ALB is retired; external-dns with the Cloudflare provider remains an option for dynamic hosts.
B. Build the tunnel side (still no user impact)
- Terraform Tunnel + routes for all four hosts:
app.→http://demo-api.demo.svc.cluster.local:8082,auth.→http://keycloak.keycloak.svc.cluster.local:8080for/realms/demoand/resourcesonly (the ALB routed every realm), with any dot-segment path (..,%2e%2e) refused first, so the admin console, its API and the master realm stay off the internet,argocd.→http://argocd-server.argocd.svc.cluster.local:80,grafana.→http://grafana.observability.svc.cluster.local:80, catch-allhttp_status:404. The token goes to Secrets Managerdemo/cloudflare/tunnel-token. Check: the tunnel status is healthy once the connector runs. - Argo
cloudflaredDeployment: 2 replicas, PDB, readiness on/ready, token fromdemo/cloudflare/tunnel-token→ ESO (its read grant is ininfra/eks). NetworkPolicies allowcloudflared→ apps (and the ALB, until it is retired). Check: 2/2 Ready, 4 connections each in the logs; a test pod in another namespace can't reach the apps. - Argo Apps trust
CF-Connecting-IPfromcloudflaredonly (rate limiter; Keycloak brute-force via forwarded headers), switched on after the ALB is retired (step 11). Until then, tunnelled requests reach the rate limiter from thecloudflaredpods' addresses, so tunnel users share those buckets. Check: a unit test and a log line show the real client IP. - Terraform Access identity provider = Keycloak (OIDC, PKCE, scopes
openid email profile, email claimemail). Terraform generates the client secret and writesdemo/keycloak/cloudflare-access; thecloudflare-accessclient inrealm.yamlreads it Argo. Check: "Test" in the Access identity-provider settings logs a realm user in and shows their email. - Terraform Access applications for
argocd./grafana.only, session 8 h, Keycloak as the only identity provider (no one-time PIN) with auto-redirect to its login page. One reusable policy: allow anyone who signs in through Keycloak; Argo CD and Grafana then apply their own roles (demo.guestis read-only in both). Never onauth.(the login happens there) orapp.. Cloudflare accepts Access applications only on an active zone, so each one is created by its host's flip (step 10), just before the record turns proxied.grafana.also gets a path-scoped Bypass application for its externally shared dashboards (/public-dashboards/*,/api/public/*,/public/*, checked against Grafana 12.3.1's routes), so the public dashboard link keeps working without a login. Optional later:cloudflared'saccessorigin check.
C. Name-server cutover (no behaviour change: the records still point at the ALB)
- CLI DNSSEC is off (checked 2026-10-17: no DS record at the registrar, the hosted zone isn't signing). Switch the name servers to the two that
terraform output nameserversprints:aws route53domains update-domain-nameservers --region us-east-1 --domain-name eksgitops.click \ --nameservers Name=<a>.ns.cloudflare.com Name=<b>.ns.cloudflare.com- Both providers serve identical records during propagation.
- Check:
dig NS eksgitops.click @1.1.1.1and@8.8.8.8show Cloudflare; the zone is active in the API, and its Universal SSL certificate is active before any host is proxied. - Cloudflare deletes a zone that stays pending for 28 days, so switch within that window of the merge.
D. Flip host by host to the tunnel (per-record rollback)
- Terraform One pull request per host sets its
host_modesentry totunnel: the record becomes a proxied CNAME to<tunnel-id>.cfargotunnel.com. Order:app.→auth.(no Access; proves the tunnel end to end), thenargocd.→grafana.(each flip creates its Access application first).- Check after each flip:
curl -sI https://<host>showsserver: cloudflare, and the host works end to end. - Rollback: revert that pull request (the host goes back to
alb). Resolvers pick it up within minutes, with no name-server change.
- Check after each flip:
E. Retire (after a few stable days)
- Argo Delete the Ingresses (the Argo CD one is in
infra/argocd), so the LB controller removes the ALB, and stop external-dns writing Route 53 (remove the add-on). Then retire ACM-on-ALB ininfra/bootstrap; the DNS mirror drops the validation record by itself. Then, in the gitops repo, drop the ALB from the NetworkPolicies and switch the apps to trustingCF-Connecting-IPfromcloudflared. Check:aws elbv2 describe-load-balancersshows no demo ALB; all four hosts still answer through the tunnel. - CLI After a few more days, delete the Route 53 hosted zone, after removing what reads it (the mirror and the
route53_nameserversoutput ininfra/cloudflare, the zone lookup ininfra/bootstrap). Before then, the last-resort rollback is name servers back to the four Route 53awsdnsservers.
8. Acceptance checks
dig NS eksgitops.clickshows Cloudflare name servers; all four hosts resolve to Cloudflare anycast IPs and returnserver: cloudflare.app./auth.work end to end through the tunnel: PKCE login, GraphQL queries, playground. No Access prompt on these hosts.argocd./grafana.show Cloudflare Access → Keycloak login → app SSO, with one password typed.- The demo guest gets through Access and is read-only in Argo CD and Grafana. Someone without a realm account can't get past the Access login (Keycloak is the only identity provider; no one-time PIN), and the
cloudflared/Argo logs show the request never arrived. - Grafana's public dashboard link opens without any login through the tunnel, while the rest of
grafana.asks for the Access login. - Per-record rollback works: flipping one record back to the ALB restores that host during the fallback window.
- After retirement: no demo ALB exists, and nothing in AWS accepts inbound traffic for the apps (no public LB, no NodePort).
- After the ALB is retired, the rate limiter and Keycloak brute-force log real client IPs (
CF-Connecting-IP). A forged header from a non-cloudflaredpod is ignored or blocked. - Last-resort rollback (name servers back to Route 53) is written down until the hosted zone is deleted.
9. Gotchas
- ACM validation record: keep it in Cloudflare while the ALB fallback exists, or the fallback's certificate can silently fail to renew. The mirror copies it from Route 53 and drops it once ACM-on-ALB is retired.
- Access must never gate Keycloak's login pages, or the login redirects loop.
- Access needs an active zone: Cloudflare rejects Access applications on a pending zone, so they appear with each host's flip, never in the first apply.
- Pending zones expire: a zone still pending 28 days after it was added is deleted by Cloudflare. Switch the name servers within that window.
- Pull requests don't plan
infra/cloudflare: they get no API token, so the first plan of a change runs onmainand is applied at once. Run the read-only plan locally (make tf-plan ROOT=cloudflarewith the token in the environment) before merging. - Non-browser clients of
argocd./grafana.(CLI, API tokens, webhooks) are stopped by Access; they need an Access service token orcloudflared access, or the in-cluster Service. - CORS preflight: browsers send
OPTIONSwithout cookies, so Access blocks them. Enable "bypass OPTIONS" or answer CORS at the edge. - HA: run 2+
cloudflaredreplicas with a PDB; one pod is a single point of failure. - Origin bypass: during the fallback window the ALB still exists. Retire it promptly, or keep the Argo/Grafana routes off it from day one.
- Client IP: trust
CF-Connecting-IPonly fromcloudflaredpeers, enforced by a NetworkPolicy. - Propagation: name-server changes take hours (up to 24–48 h). Never cut over close to a demo.
- Secrets: the API token and tunnel token live in Secrets Manager, never in Git or chat. The
infra/cloudflarestate also holds the tunnel token and the Access client secret, so the pull-request plan role is denied that state, as it is denied Secrets Manager values. - What the Free plan's WAF is: the Free Managed Ruleset (on by default), 5 custom rules and 1 rate-limiting rule, whose expression can use only the path and verified bots, counted per IP over 10 s with a 10 s block. The full Cloudflare Managed Ruleset needs Pro or higher. Nothing here is a full WAF.
- Account lockout by strangers: Keycloak's brute-force protection locks a user (10 failures, then waits up to 15 min), and the demo usernames are published, so anyone can lock a demo account. Follow-up: the Free rate-limiting rule on
/realms/demo/login-actions/authenticate(path only on Free, so page loads count too). It needs Zone · Zone WAF · Edit on the API token, which the token doesn't have. - Drift: dashboard edits drift from Terraform. Manage Cloudflare as code and treat the dashboard as read-only.
Cost: Cloudflare Free + Zero Trust Free = US$0; the domain is unchanged. Retiring the ALB saves about US$20/month, and the hosted zone another US$0.50/month.
10. Config snippets (Terraform excerpts from infra/cloudflare, provider v5.28; the Kubernetes parts are sketches for the gitops repo)
cloudflared Deployment (Argo)
apiVersion: apps/v1
kind: Deployment
metadata: { name: cloudflared, namespace: cloudflare }
spec:
replicas: 2
selector: { matchLabels: { app: cloudflared } }
template:
metadata: { labels: { app: cloudflared } }
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:<pinned>
args: ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:2000", "run"]
env:
- name: TUNNEL_TOKEN
valueFrom: { secretKeyRef: { name: cloudflared-token, key: token } } # ESO from demo/cloudflare/tunnel-token (plain string)
readinessProbe: { httpGet: { path: /ready, port: 2000 } }
securityContext: { runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false }
Terraform: hosts, the flip, tunnel routes (dns.tf, tunnel.tf)
locals {
hosts = {
app = { service = "http://demo-api.demo.svc.cluster.local:8082", path = null, access = false, public_paths = [] }
auth = { service = "http://keycloak.keycloak.svc.cluster.local:8080", path = "^/(realms/demo|resources)(/|$)", access = false, public_paths = [] }
argocd = { service = "http://argocd-server.argocd.svc.cluster.local:80", path = null, access = true, public_paths = [] }
grafana = { service = "http://grafana.observability.svc.cluster.local:80", path = null, access = true,
public_paths = ["/public-dashboards/*", "/api/public/*", "/public/*"] } # Bypass application
}
}
# variables.tf: host_modes = { app = "alb", auth = "alb", argocd = "alb", grafana = "alb" }
resource "cloudflare_dns_record" "host" {
for_each = local.hosts
zone_id = cloudflare_zone.this.id
name = "${each.key}.${var.domain}"
type = "CNAME"
content = var.host_modes[each.key] == "tunnel" ? local.tunnel_cname : local.alb_dns_name
proxied = var.host_modes[each.key] == "tunnel"
ttl = var.host_modes[each.key] == "tunnel" ? 1 : 60
depends_on = [cloudflare_zero_trust_access_application.this] # Access before proxied
}
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "eks" {
account_id = var.account_id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.eks.id
config = {
ingress = concat(
flatten([for host, cfg in local.hosts : concat(
cfg.path == null ? [] : [{ hostname = "${host}.${var.domain}", path = "\\.\\.", service = "http_status:404" }], # dot segments
[{ hostname = "${host}.${var.domain}", path = cfg.path, service = cfg.service }],
)]),
[{ hostname = null, path = null, service = "http_status:404" }], # catch-all
)
}
}
Terraform: Keycloak as IdP, the policy, the applications (access.tf)
resource "cloudflare_zero_trust_access_identity_provider" "keycloak" {
account_id = var.account_id
name = "Keycloak"
type = "oidc"
config = {
client_id = "cloudflare-access"
client_secret = random_password.access_client_secret.result # also written to demo/keycloak/cloudflare-access
auth_url = "${local.issuer}/protocol/openid-connect/auth"
token_url = "${local.issuer}/protocol/openid-connect/token"
certs_url = "${local.issuer}/protocol/openid-connect/certs"
scopes = ["openid", "email", "profile"]
email_claim_name = "email"
pkce_enabled = true
}
}
resource "cloudflare_zero_trust_access_policy" "keycloak_users" {
account_id = var.account_id
name = "Signed in with Keycloak"
decision = "allow"
include = [{ login_method = { id = cloudflare_zero_trust_access_identity_provider.keycloak.id } }]
}
resource "cloudflare_zero_trust_access_application" "this" {
for_each = { for host, cfg in local.hosts : host => cfg if cfg.access && var.host_modes[host] == "tunnel" }
account_id = var.account_id
type = "self_hosted"
domain = "${each.key}.${var.domain}"
destinations = [{ type = "public", uri = "${each.key}.${var.domain}" }]
session_duration = "8h"
allowed_idps = [cloudflare_zero_trust_access_identity_provider.keycloak.id]
auto_redirect_to_identity = true
policies = [{ id = cloudflare_zero_trust_access_policy.keycloak_users.id, precedence = 1 }]
}
# The more specific application wins: grafana.'s externally shared dashboards stay public.
resource "cloudflare_zero_trust_access_policy" "public_paths" {
account_id = var.account_id
name = "Public paths (bypass)"
decision = "bypass"
include = [{ everyone = {} }]
}
resource "cloudflare_zero_trust_access_application" "public" {
for_each = { for host, cfg in local.hosts : host => cfg if length(cfg.public_paths) > 0 && var.host_modes[host] == "tunnel" }
account_id = var.account_id
type = "self_hosted"
domain = "${each.key}.${var.domain}${each.value.public_paths[0]}"
destinations = [for path in each.value.public_paths : { type = "public", uri = "${each.key}.${var.domain}${path}" }]
policies = [{ id = cloudflare_zero_trust_access_policy.public_paths.id, precedence = 1 }]
}
Keycloak: client entry in realm.yaml (sketch; the gitops repo owns it)
clients:
- clientId: cloudflare-access
protocol: openid-connect
publicClient: false
secret: "$(env:CLOUDFLARE_ACCESS_CLIENT_SECRET)" # keycloak-config-cli substitution; ESO from demo/keycloak/cloudflare-access (client_secret)
standardFlowEnabled: true
redirectUris: ["https://eksgitops.cloudflareaccess.com/cdn-cgi/access/callback"]
defaultClientScopes: [basic, email, profile] # Access needs the email claim
Alternative (dynamic hosts): external-dns with the Cloudflare provider
provider: { name: cloudflare }
env:
- name: CF_API_TOKEN
valueFrom: { secretKeyRef: { name: cloudflare-api-token, key: token } }
domainFilters: ["eksgitops.click"]
txtOwnerId: eks-gitops-demo
extraArgs: ["--cloudflare-dns-records-per-page=5000"] # proxied flag per record via annotation
# external-dns.alpha.kubernetes.io/cloudflare-proxied