eks-gitops-demo DecisionToday vs targetMental modelsIdentity & tokensFull vs partialPrerequisitesRunbookAcceptanceGotchasSnippetsPlatform →

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:

Colour key:

Cloudflare (edge, Tunnel, Access)Terraform-managed AWSWorkloads (Argo CD)ALB (LB controller)Identity (Keycloak)You / browser

1. Decision

Same domain, full setup (chosen)Second domain only for Cloudflare (considered)
RealismHow organisations onboard: their existing domain moves behind CloudflareA split that real setups rarely use
Public app protectionEvery 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 runOne DNS provider, one record set, one domain in redirect URIs and SSOTwo of each
Risk to the working setupA name-server switch with a propagation window (hours, up to 24–48 h)None
CostUS$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 partsOne ingress path; the ALB, LB-controller Ingresses, ACM-on-ALB and external-dns are retiredTwo ingress paths to run and explain
ExposureNothing in AWS accepts inbound trafficA public ALB remains (must be locked to Cloudflare ranges if proxied)
Client IPApps trust CF-Connecting-IP from cloudflared only; a NetworkPolicy makes it unspoofableALB plus proxy header handling
CostAbout US$20/month saved (no ALB)ALB stays
WhenTarget stateExists 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

Left: today, Route 53 and a public ALB serve all four hosts. Right: target, Cloudflare answers DNS and proxies all four hosts through a tunnel to cloudflared and ClusterIP Services; Access gates argocd and grafana; the ALB is retired after a fallback window. Today (Phases 2–7) Browserany user Route 53 zonerecords by external-dns ACM certificate*.eksgitops.click on the ALB Public ALBHTTPS 443, all 4 hosts EKS demo-apiapp. Keycloakauth. Argo CDargocd. (public login page) Grafanagrafana. (public login page) DNS Target (Phase 8) Browserany user Cloudflare edge DNS for the whole zoneUniversal SSL (apex + *.)proxy: DDoS · Free ManagedRuleset on ALL four hostsAccess: argocd., grafana.(login via Keycloak; neveron auth. login pages) ALB (fallback only)kept a few days, then deleted EKS cloudflared ×2connector, outbound only demo-apiapp. · ClusterIP Keycloakauth. · ClusterIP Argo CDargocd. · Access Grafanagrafana. · Access tunnel

Cluster-side changes:

3. Mental models

Client → edge → connector → origin

Four positions: client browser, Cloudflare edge in Cloudflare data centres, cloudflared connector in your cluster, and your origin apps. your AWS account / EKS cluster Clientbrowser, mobile app, scriptoptional WARP agent onmanaged staff laptops Edge (Cloudflare data centres)TLS termination, DDoS, managed rulesAccess login + policy, checks CF tokenthe ONLY place requests are judgednever the container in your cluster Connectorcloudflared Deployment (×2)dials OUT, keeps the tunnelforwards hostname → Service OriginArgo CD, Grafana, demo-api,Keycloak behind ClusterIPServices, no inbound exposure HTTPS tunnel HTTP
QuestionAnswer
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

RoleKeycloakCloudflare Access
What it isIdentity 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)
IssuesID, access and refresh tokensIts own short-lived session JWT, cookie CF_Authorization
Decides access byRoles and claims inside the access token, enforced by the APIPolicies per application: Include (OR), Require (AND), Exclude (NOT) on groups, email, country, device posture, MFA; actions Allow / Block / Bypass / Service Auth
MachinesClient credentials (service accounts)Service tokens (two headers) at the edge

Who checks which token

TokenIssued byChecked byAnalogy
CF_Authorization cookieCloudflareThe edge, on every request; denied requests never reach the clusterBuilding pass at the front door
Cf-Access-Jwt-Assertion headerCloudflare (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)KeycloakThe GraphQL API: signature, iss, aud=demo-api, exp, rolesBank card used at the counter
ID tokenKeycloakThe client only ("logged in as …")Name badge
Refresh tokenKeycloakKeycloak only (rotated on use)Card renewal slip

Login flow: one password, several gates

Swimlane of eight steps: browser opens argocd, edge redirects to Keycloak, password once, back-channel token call, Cloudflare sets its cookie, Argo performs its own silent SSO, SPA gets an API token silently. Browser CF edge Keycloak Apps ① open argocd.no CF cookie ② redirectto Keycloak login ③ login pagepassword (+MFA) ④ code backserver-to-server ⑤ policy OKsets CF cookie ⑥ Argo receivesstarts its own OIDC ⑦ SSO cookieno password again ⑧ signed inArgo RBAC applies

5. Full vs partial vs subdomain setup

SetupHowPlanFor us
FullThe domain's name servers point at Cloudflare; Cloudflare answers DNS for every nameFree and up✅ Chosen: it's the only option on Free
Partial (CNAME)DNS stays at Route 53; chosen names are CNAMEd to CloudflareBusiness and up❌ Paid
SubdomainDelegate only ops.eksgitops.click to CloudflareEnterprise❌ 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)

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)

  1. Terraform Zone (pending until the switch) and its settings: SSL Full (strict), minimum TLS 1.2, Always Use HTTPS. Check: the tf-apply summary shows infra/cloudflare applied; the next plan shows no changes.
  2. 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).
    Check: dig @<cf-ns> app.eksgitops.click returns a CNAME to the ALB name that Route 53's alias resolves to; the ACM CNAME is identical.
  3. 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)

  1. Terraform Tunnel + routes for all four hosts: app. → http://demo-api.demo.svc.cluster.local:8082, auth. → http://keycloak.keycloak.svc.cluster.local:8080 for /realms/demo and /resources only (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-all http_status:404. The token goes to Secrets Manager demo/cloudflare/tunnel-token. Check: the tunnel status is healthy once the connector runs.
  2. Argo cloudflared Deployment: 2 replicas, PDB, readiness on /ready, token from demo/cloudflare/tunnel-token → ESO (its read grant is in infra/eks). NetworkPolicies allow cloudflared → 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.
  3. Argo Apps trust CF-Connecting-IP from cloudflared only (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 the cloudflared pods' addresses, so tunnel users share those buckets. Check: a unit test and a log line show the real client IP.
  4. Terraform Access identity provider = Keycloak (OIDC, PKCE, scopes openid email profile, email claim email). Terraform generates the client secret and writes demo/keycloak/cloudflare-access; the cloudflare-access client in realm.yaml reads it Argo. Check: "Test" in the Access identity-provider settings logs a realm user in and shows their email.
  5. 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.guest is read-only in both). Never on auth. (the login happens there) or app.. 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's access origin check.

C. Name-server cutover (no behaviour change: the records still point at the ALB)

  1. 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 nameservers prints:
    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.1 and @8.8.8.8 show 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)

  1. Terraform One pull request per host sets its host_modes entry to tunnel: the record becomes a proxied CNAME to <tunnel-id>.cfargotunnel.com. Order: app. → auth. (no Access; proves the tunnel end to end), then argocd. → grafana. (each flip creates its Access application first).
    • Check after each flip: curl -sI https://<host> shows server: 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.

E. Retire (after a few stable days)

  1. 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 in infra/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 trusting CF-Connecting-IP from cloudflared. Check: aws elbv2 describe-load-balancers shows no demo ALB; all four hosts still answer through the tunnel.
  2. CLI After a few more days, delete the Route 53 hosted zone, after removing what reads it (the mirror and the route53_nameservers output in infra/cloudflare, the zone lookup in infra/bootstrap). Before then, the last-resort rollback is name servers back to the four Route 53 awsdns servers.

8. Acceptance checks

9. Gotchas

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
← PlatformApp & observability →