Cloudflare GitOps via Flux
This runbook provides end-to-end instructions on how to deploy and manage Cloudflare and the Cloudflare CA issuer onto the cluster using GitOps automation via Flux.
- The Origin CA Issuer controller (from Cloudflare’s upstream repo) via Flux
- A Cloudflare namespace with a local‑managed cloudflared tunnel, Origin CA certs, Ingress, and NetworkPolicies
- GitOps flow from GitLab (Flux continuously reconciles everything)
- SOPS to keep Kubernetes Secrets encrypted in Git
- This playbook has been tested on:
- Kubernetes version:
v1.31.9 - OS-Image:
Ubuntu 24.04.2 LTS - Kernel version:
6.8.0-62-generic - Container runtime:
containerd://2.0.5
- Kubernetes version:
1.0 - Environment assumptions
Environment assumptions
- Flux v2 already bootstrapped (
flux-systemnamespace exists). - You use an “infra” repo (Flux root) called
flux-config(GitRepository name:flux-systeminflux-systemns). - You will create a new “app” repo for Cloudflare stack:
muppit-apps/cloudflare. - Ingress is ingress-nginx. cert-manager is installed and healthy.
- Cloudflare account and a local-managed named tunnel created on your Mac (
cloudflared tunnel create ...) so you have a credentials.json file.
1.0.1 Prerequisites
- Cluster add‑ons present and Ready
cert-manager(CRDs + controller + webhook).ingress-nginx(LB IP/ClusterIP reachable from pods).
- Flux already installed and syncing your infra repo (called here flux-config). Flux root points to:
./clusters/my-cluster. - GitLab (self-hosted): repos available under your group, e.g.
muppit-appsandfluxgitops. - Domains:
muppit.aumanaged in Cloudflare; you have permission to manage DNS, Tunnels, and Page Rules/Redirect Rules.
1.0.2 Create the Cloudflare app repo (GitLab)
Create a blank project (web UI) before you start (no README):
- App repo for Cloudflare stack:
https://gitlab.reids.net.au/muppit-apps/cloudflare.git- Group:
muppit-apps - Project name:
cloudflare - We’ll push K8s manifests (namespace, Secrets, ConfigMap, Deployment, etc.) under
k8s/prod/in this repo.
- Group:
- Infra repo (already present):
https://gitlab.reids.net.au/fluxgitops/flux-config.git
SSH vs HTTPS note
Your GitLab usesgit-ssh.reids.net.aufor SSH on a custom port. From your Mac you prefer HTTPS clones. Flux can continue using SSH or HTTPS; adjust URLs accordingly.
1.1 - Flux root aggregator (infra repo)
Confirm that the flux root points to ./clusters/my-cluster in flux-config. If not adjust all configuration to match your flux root.
kubectl -n flux-system get kustomization flux-system -o jsonpath='{.spec.path}'; echo
Clone the flux-config project (Mac) into Projects directory and change directory to the flux-config root (all git commands will be run from here):
git clone https://gitlab.reids.net.au/fluxgitops/flux-config.git
cd flux-config
Create the aggregator clusters/my-cluster/kustomization.yaml if it does not exist and comment out any resources you do not want to manage, or will be adding later. The origin-ca-issuer is commented out intially until all the configuration has been created and pushed to the repo.
# clusters/my-cluster/kustomization.yaml (in flux-config repo)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ./flux-system
- ./apps
- ./dev
# - ./prod
# - ./origin-ca-issuer
# - ./cloudflare
Create placeholder kustomizations in each child folder if they don’t exist (Flux requires a kustomization file in any referenced directory). Example:
# clusters/my-cluster/origin-ca-issuer/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- source.yaml
- 00-kustomization-ns.yaml
- 10-kustomization-crds.yaml
- 20-kustomization-rbac.yaml
- 30-kustomization-controller.yaml
Commit & push; reconcile root:
git add .
git commit -m "Add aggregator"
git push
flux reconcile kustomization flux-system -n flux-system --with-source
1.2 - Repo and image pinning strategy
Pin a repo and image for deterministic builds with audit changes via Git diffs. Use a scheduled workflow (or manual) to change tags with a PR.
1.2.1 Pin controller repo to a release tag
Recommended approach.
How to pin Origin CA Issuer to a specific release such as v0.12.1:
- Find tags:
git ls-remote --tags --refs https://github.com/cloudflare/origin-ca-issuer.git - Edit
clusters/my-cluster/origin-ca-issuer/source.yamlto the newref.tag:; commit & push. - Reconcile:
flux reconcile source git origin-ca-issuer-upstream -n flux-system && \
flux reconcile kustomization origin-ca-issuer-crds -n flux-system && \
flux reconcile kustomization origin-ca-issuer-rbac -n flux-system && \
flux reconcile kustomization origin-ca-issuer-controller -n flux-system
1.2.2 Pin container images
Cloudflared, your apps.
Find “latest” semantic calendar tag with crane (installed via brew install crane):
crane ls cloudflare/cloudflared \
| grep -E '^[0-9]{4}\.[0-9]+(\.[0-9]+)?$' \
| sort -V | tail -n1
# e.g. 2025.9.1
Update the Deployment image: to that exact tag and commit.
Repeat this pattern for all app images to ensure reproducible rollouts.
1.2.3 Intervals
Keep spec.interval in Flux sources/kustomizations (e.g., 30m) — Flux uses it to refresh artifacts & status, and to re-apply drifted resources. With a pinned tag, Flux won’t pull newer commits automatically, but the interval still governs reconciliation/health.
1.2.4 GitLab CE limitations
In GitLab CE (free tier) the mirror direction selector is hard-coded to Push and the Pull option is greyed out. This means it does not support mirroring from https://github.com/cloudflare/origin-ca-issuer.git or pinning a brach from the public repo.
1.3 - Origin CA Issuer (controller)
Via Cloudflare upstream (PINNED).
We’ll deploy Cloudflare’s controller origin-ca-issuer from their official repo and pin to a released tag so it’s stable.
The Namespace was put in its own sub-directory to make the Namespace its own reconciliation and prune boundary and provide:
- Reliable ordering so the namespace exists before CRDs/RBAC/controller apply.
- Safer teardown so you can remove or rebuild the controller without accidentally deleting the namespace.
- A tidy place to keep namespace-scoped defaults like labels, quotas, imagePullSecrets and NetworkPolicies.
- A predictable repo pattern that is easy to scan.
1.3.1 Clone the flux-config repo
Create origin-ca-issuer structure.
git clone https://gitlab.reids.net.au/fluxgitops/flux-config.git
cd flux-config
mkdir -p clusters/my-cluster/origin-ca-issuer/ns
Note that the -p flag will create any missing parent directories along the path.
1.3.2 Create manifests (plaintext)
Create the following files under clusters/my-cluster/origin-ca-issuer/.
Note that I remain in the root directory
1.3.2.1 Create namespace
# clusters/my-cluster/origin-ca-issuer/ns/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: origin-ca-issuer
1.3.2.2 Create Kustomization
# clusters/my-cluster/origin-ca-issuer/00-kustomization-ns.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: origin-ca-issuer-ns
namespace: flux-system
spec:
interval: 10m
prune: true
path: ./clusters/my-cluster/origin-ca-issuer/ns
sourceRef:
kind: GitRepository
name: flux-system
namespace: flux-system
timeout: 2m
1.3.2.3 Create upstream Git source
Pinned to release
v0.12.1.
# clusters/my-cluster/origin-ca-issuer/source.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: origin-ca-issuer-upstream
namespace: flux-system
spec:
interval: 30m
url: https://github.com/cloudflare/origin-ca-issuer
ref:
tag: v0.12.1
timeout: 2m
1.3.2.4 Create CRDs → RBAC → controller
# clusters/my-cluster/origin-ca-issuer/10-kustomization-crds.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: origin-ca-issuer-crds
namespace: flux-system
spec:
dependsOn:
- name: origin-ca-issuer-ns
interval: 30m
prune: true
path: ./deploy/crds
sourceRef:
kind: GitRepository
name: origin-ca-issuer-upstream
namespace: flux-system
timeout: 2m
1.3.2.4 Create RBAC
# clusters/my-cluster/origin-ca-issuer/20-kustomization-rbac.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: origin-ca-issuer-rbac
namespace: flux-system
spec:
dependsOn:
- name: origin-ca-issuer-crds
interval: 30m
prune: true
path: ./deploy/rbac
sourceRef:
kind: GitRepository
name: origin-ca-issuer-upstream
namespace: flux-system
timeout: 2m
1.3.2.5 Create controller
# clusters/my-cluster/origin-ca-issuer/30-kustomization-controller.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: origin-ca-issuer-controller
namespace: flux-system
spec:
dependsOn:
- name: origin-ca-issuer-rbac
interval: 30m
prune: true
path: ./deploy/manifests
sourceRef:
kind: GitRepository
name: origin-ca-issuer-upstream
namespace: flux-system
timeout: 2m
targetNamespace: origin-ca-issuer
1.3.3 Commit and push
git add .
git commit -m "init origin-ca-controller"
git push
1.3.4 Confirm flux-config repo layout
Final repo layout:
tree -a -I '.git|.DS_Store'
.
└── clusters
└── my-cluster
├── apps
│ ├── app-manifests-kustomization.yaml
│ ├── app-manifests-source.yaml
│ └── kustomization.yaml
├── cloudflare
│ ├── .gitkeep
│ └── ns
├── dev
│ ├── coach-app-dev-kustomization.yaml
│ ├── coach-app-dev-source.yaml
│ └── kustomization.yaml
├── flux-system
│ ├── gotk-components.yaml
│ ├── gotk-sync.yaml
│ └── kustomization.yaml
├── kustomization.yaml
├── origin-ca-issuer
│ ├── .gitkeep
│ ├── 00-kustomization-ns.yaml
│ ├── 10-kustomization-crds.yaml
│ ├── 20-kustomization-rbac.yaml
│ ├── 30-kustomization-controller.yaml
│ ├── kustomization.yaml
│ ├── ns
│ │ └── namespace.yaml
│ └── source.yaml
└── prod
└── .gitkeep
11 directories, 20 files
1.3.5 Reconcile and verify
-
Interval on a Kustomization controls periodic drift checks and applies. Sources and Kustomizations both reconcile on their own intervals.
kubectl -n flux-system get gitrepositories -o custom-columns=NAME:.metadata.name,INTERVAL:.spec.intervalNAME INTERVAL
app-manifests 1m0s
coach-app-dev 5m0s
flux-system 5m0s
origin-ca-issuer-upstream 30mkubectl -n flux-system get kustomizations -o custom-columns=NAME:.metadata.name,INTERVAL:.spec.intervalNAME INTERVAL
app-source-kustomization 2m0s
coach-app-dev 10m0s
flux-system 10m0s
origin-ca-issuer-controller 30m
origin-ca-issuer-crds 30m
origin-ca-issuer-ns 30m
origin-ca-issuer-rbac 30m -
Even though origin-ca-issuer-*- Kustomizations reconcile on their own timers, new upstream changes will only be noticed after the Git source refreshes, which is every 30m:
- GitRepository origin-ca-issuer-upstream: every 30m.
- Kustomization origin-ca-issuer-ns (creates the Namespace): every 30m.
- Kustomizations origin-ca-issuer-crds, origin-ca-issuer-rbac, origin-ca-issuer-controller: every 30m.
-
Force reconciliation immediately (instead of waiting for configured interval):
flux reconcile source git origin-ca-issuer-upstream -n flux-system && \
flux reconcile kustomization origin-ca-issuer-crds -n flux-system && \
flux reconcile kustomization origin-ca-issuer-rbac -n flux-system && \
flux reconcile kustomization origin-ca-issuer-controller -n flux-system► annotating GitRepository origin-ca-issuer-upstream in flux-system namespace
✔ GitRepository annotated
◎ waiting for GitRepository reconciliation
✔ fetched revision trunk@sha1:debc90bd0e97881293fc8631fb56acdccfea9806
► annotating Kustomization origin-ca-issuer-crds in flux-system namespace
✔ Kustomization annotated
◎ waiting for Kustomization reconciliation
✔ applied revision trunk@sha1:debc90bd0e97881293fc8631fb56acdccfea9806
► annotating Kustomization origin-ca-issuer-rbac in flux-system namespace
✔ Kustomization annotated
◎ waiting for Kustomization reconciliation
✔ applied revision trunk@sha1:debc90bd0e97881293fc8631fb56acdccfea9806
► annotating Kustomization origin-ca-issuer-controller in flux-system namespace
✔ Kustomization annotated
◎ waiting for Kustomization reconciliation
✔ applied revision trunk@sha1:debc90bd0e97881293fc8631fb56acdccfea9806 -
Or force reconciliation with one command:
flux reconcile kustomization flux-system -n flux-system --with-source► annotating GitRepository flux-system in flux-system namespace
✔ GitRepository annotated
◎ waiting for GitRepository reconciliation
✔ fetched revision main@sha1:53f1413ba96cbf6154d9a4d8766adb64cd7be448
► annotating Kustomization flux-system in flux-system namespace
✔ Kustomization annotated
◎ waiting for Kustomization reconciliation
✔ applied revision main@sha1:53f1413ba96cbf6154d9a4d8766adb64cd7be448 -
Verify:
- All should be in the ready state:
flux get sources git -n flux-systemNAME REVISION SUSPENDED READY MESSAGE
app-manifests main@sha1:2c4e91b2 False True stored artifact for revision 'main@sha1:2c4e91b2'
coach-app-dev dev@sha1:9c3ba21c False True stored artifact for revision 'dev@sha1:9c3ba21c'
flux-system main@sha1:96b9b2e1 False True stored artifact for revision 'main@sha1:96b9b2e1'
origin-ca-issuer-upstream v0.12.1@sha1:86d908ed False True stored artifact for revision 'v0.12.1@sha1:86d908ed'flux get kustomizations -n flux-systemNAME REVISION SUSPENDED READY MESSAGE
app-source-kustomization main@sha1:2c4e91b2 False True Applied revision: main@sha1:2c4e91b2
coach-app-dev dev@sha1:9c3ba21c False True Applied revision: dev@sha1:9c3ba21c
flux-system main@sha1:b4948359 False True Applied revision: main@sha1:b4948359
origin-ca-issuer-controller trunk@sha1:debc90bd False True Applied revision: trunk@sha1:debc90bd
origin-ca-issuer-crds trunk@sha1:debc90bd False True Applied revision: trunk@sha1:debc90bd
origin-ca-issuer-ns main@sha1:b4948359 False True Applied revision: main@sha1:b4948359
origin-ca-issuer-rbac trunk@sha1:debc90bd False True Applied revision: trunk@sha1:debc90bd- Confirm deployment:
kubectl -n origin-ca-issuer get deploy,podsNAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/origin-ca-issuer 1/1 1 1 4h27m
NAME READY STATUS RESTARTS AGE
pod/origin-ca-issuer-7c8b955544-6trhh 1/1 Running 0 4h27m- Confirm version:
kubectl -n flux-system get gitrepository origin-ca-issuer-upstream -o jsonpath='{.status.artifact.revision}'; echo
# expect: v0.12.1@sha1:...v0.12.1@sha1:86d908eda6c91815557c04b7f3e871ca4f0a0cce
1.4 - SOPS & Age (secrets encryption)
SOPS is a Git-friendly encryption tool for Kubernetes manifests that encrypts only secret fields using Age; it isn’t built into Kubernetes itself, but Flux supports it natively so secrets are safely stored in Git and decrypted inside the cluster.
Create the cluster Secret
sops-ageinflux-systemfrom your Mac.
Generate key (Mac):
mkdir -p ~/.sops
age-keygen -o ~/.sops/age.key
# Save the shown "public key: age1..." for .sops.yaml
Store the private key in the cluster so Flux can decrypt:
kubectl -n flux-system create secret generic sops-age \
--from-file=age.agekey=$HOME/.sops/age.key \
-o yaml --dry-run=client | kubectl apply -f -
Note: age keys don’t expire automatically. Rotate when you choose (see Operations).
1.5 - Cloudflare stack cloudflare
App repo:
muppit-apps/cloudflare.git.
1.5.1 Prepare the App Repo in GitLab
On your Mac.
1.5.1.1 Create the GitLab project
- Project:
muppit-apps/cloudflare - SSH URL (example):
ssh://git-ssh.reids.net.au/muppit-apps/cloudflare.git
(scp style also works:git@git-ssh.reids.net.au:muppit-apps/cloudflare.git)
1.5.1.2 Give Flux read access
Deploy Key.
- In the new GitLab project → Settings → Repository → Deploy Keys
- Enable the same Flux deploy key you already use (
flux-ssh-auth), or add its public key again. - If you must recover the public key from the cluster (prefer the original copy instead):
kubectl -n flux-system get secret flux-ssh-auth \
-o jsonpath='{.data.identity}' | base64 -d > /tmp/flux_id
ssh-keygen -y -f /tmp/flux_id > /tmp/flux_id.pub
# paste /tmp/flux_id.pub into Deploy Keys (Read-only)
1.5.2 Clone the app repo
Create repo structure.
git clone https://gitlab.reids.net.au/muppit-apps/cloudflare.git
cd cloudflare
mkdir -p k8s/prod
Repo layout on your Mac:
tree -a -I '.git|.DS_Store' # Initial structure
Add a SOPS policy at repo root:
# .sops.yaml
creation_rules:
- path_regex: k8s/.*\.enc\.ya?ml$
encrypted_regex: '^(data|stringData)$'
age: ['AGE_PUBLIC_KEY_HERE']
Replace AGE_PUBLIC_KEY_HERE with your public key (the one starting with age1...).
1.5.3 Create manifests (plaintext)
Create the following files under k8s/prod/.
1.5.3.1 Minimal RBAC for Origin CA Issuer
Namespace-scoped.
# k8s/prod/10-rbac-origin-issuer-minimal.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: origin-ca-issuer-writer
namespace: cloudflare
rules:
- apiGroups: ["cert-manager.k8s.cloudflare.com"]
resources: ["originissuers"]
verbs: ["get","list","watch"]
- apiGroups: ["cert-manager.k8s.cloudflare.com"]
resources: ["originissuers/status"]
verbs: ["get","update","patch"]
- apiGroups: ["cert-manager.io"]
resources: ["certificaterequests"]
verbs: ["get","list","watch"]
- apiGroups: ["cert-manager.io"]
resources: ["certificaterequests/status"]
verbs: ["get","update","patch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get","list","watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: origin-ca-issuer-writer
namespace: cloudflare
subjects:
- kind: ServiceAccount
name: originissuer-control
namespace: origin-ca-issuer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: origin-ca-issuer-writer
1.5.3.2 OriginIssuer (ECC)
# k8s/prod/21-originissuer.yaml
apiVersion: cert-manager.k8s.cloudflare.com/v1
kind: OriginIssuer
metadata:
name: cf-origin
namespace: cloudflare
spec:
requestType: OriginECC # or OriginRSA
auth:
tokenRef:
name: cfapi-token
key: key
1.5.3.3 Origin CA root pool (ECC)
# k8s/prod/30-configmap-origin-ca-pool.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cf-origin-capool
namespace: cloudflare
data:
origin_ca.pem: |
# Paste the ECC Origin CA root PEM here (from Cloudflare docs)
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
1.5.3.4 cloudflared config
Tunnel → ingress-nginx over TLS.
# k8s/prod/40-configmap-cloudflared.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared
namespace: cloudflare
data:
config.yaml: |
tunnel: <TUNNEL-UUID> # from `cloudflared tunnel list`
credentials-file: /etc/cloudflared/creds/credentials.json
metrics: 0.0.0.0:2000
ingress:
# (optional) Block WP admin to public
- hostname: muppit.au
path: ^/(wp-login\.php|wp-admin(?:/.*)?$)
service: http_status:403
# Send to ingress-nginx over TLS and verify with Origin CA
- hostname: muppit.au
service: https://ingress-nginx-controller.ingress-nginx.svc.cluster.local:443
originRequest:
originServerName: muppit.au
caPool: /etc/cloudflared/certs/origin_ca.pem
# Catch-all
- service: http_status:404
1.5.3.5 cloudflared Deployment
PIN CONTAINER TAG.
Use a pinned image tag (using crane to find a suitable version).
# k8s/prod/42-deploy-cloudflared.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflare
spec:
replicas: 1
selector:
matchLabels: { app: cloudflared }
template:
metadata:
labels: { app: cloudflared }
spec:
serviceAccountName: default
containers:
- name: cloudflared
image: cloudflare/cloudflared:2025.9.1 # <— PINNED example
args: ["tunnel","--config","/etc/cloudflared/config/config.yaml","run"]
ports:
- containerPort: 2000
name: metrics
volumeMounts:
- name: config
mountPath: /etc/cloudflared/config
- name: creds
mountPath: /etc/cloudflared/creds
- name: capool
mountPath: /etc/cloudflared/certs
volumes:
- name: config
configMap: { name: cloudflared, items: [{ key: config.yaml, path: config.yaml }] }
- name: creds
secret: { secretName: cloudflared-credentials }
- name: capool
configMap: { name: cf-origin-capool, items: [{ key: origin_ca.pem, path: origin_ca.pem }] }
1.5.3.6 Certificate (Origin CA) for muppit.au
# k8s/prod/50-certificate-muppit.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: muppit-au-origin
namespace: cloudflare
spec:
secretName: muppit-au-origin-tls
dnsNames: ["muppit.au"]
issuerRef:
group: cert-manager.k8s.cloudflare.com
kind: OriginIssuer
name: cf-origin
1.5.3.7 Hello test app + Ingress
# k8s/prod/60-hello-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-app
namespace: cloudflare
spec:
replicas: 1
selector:
matchLabels: { app: hello }
template:
metadata:
labels: { app: hello }
spec:
containers:
- name: hello
image: gcr.io/google-samples/hello-app:2.0
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: hello-service
namespace: cloudflare
spec:
type: ClusterIP
selector: { app: hello }
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-muppit-au
namespace: cloudflare
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
spec:
ingressClassName: nginx
tls:
- hosts: ["muppit.au"]
secretName: muppit-au-origin-tls
rules:
- host: muppit.au
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-service
port:
number: 80
1.5.3.8 Network Policies
# k8s/prod/70-networkpolicies.yaml
# Allow only ingress-nginx to reach hello pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-nginx-to-hello
namespace: cloudflare
spec:
podSelector:
matchLabels: { app: hello }
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 8080
---
# (Optional) Default deny for other workloads in cloudflare ns
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: cloudflare
spec:
podSelector: {}
policyTypes: ["Ingress","Egress"]
Label your ingress controller namespace (once):
kubectl label ns ingress-nginx ingress=nginx --overwrite
kubectl get ns ingress-nginx --show-labels
1.5.4 Create the two Secrets (plaintext files)
Then encrypt with SOPS.
Cloudflare API token (replace the value):
cat > k8s/prod/20-secret-cfapi-token.enc.yaml << 'YAML'
apiVersion: v1
kind: Secret
metadata:
name: cfapi-token
namespace: cloudflare
type: Opaque
stringData:
key: PASTE_YOUR_API_TOKEN
YAML
Tunnel credentials Secret:
- Open your tunnel credentials.json from
cloudflared tunnel create(it may be named as<UUID>.json). - Ensure it is valid JSON (trim any trailing
%etc.). Quick check:jq -e . < /path/to/credentials.json >/dev/null && echo "OK JSON" || echo "Invalid JSON" - Put the entire JSON under key
credentials.json(the key name must be exactlycredentials.json):
cat > k8s/prod/41-secret-cloudflared-credentials.enc.yaml << 'YAML'
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-credentials
namespace: cloudflare
type: Opaque
stringData:
credentials.json: |
{ PASTE_THE_ENTIRE_JSON_ONE_INDENTED_BLOCK_HERE }
YAML
Encrypt the Secrets (on your Mac):
sops --encrypt --in-place k8s/prod/20-secret-cfapi-token.enc.yaml
sops --encrypt --in-place k8s/prod/41-secret-cloudflared-credentials.enc.yaml
Verify they’re encrypted:
head -n 20 k8s/prod/20-secret-cfapi-token.enc.yaml
head -n 20 k8s/prod/41-secret-cloudflared-credentials.enc.yaml
grep -n 'sops:' k8s/prod/*.enc.yaml
1.5.5 Commit and push
git add .
git commit -m "init cloudflare stack (SOPS-encrypted secrets)"
git push
1.5.6 Confirm repo layout
Final repo layout:
tree -a -I '.git|.DS_Store'
.
├── .sops.yaml
└── k8s
└── prod
├── 00-namespace.yaml
├── 10-rbac-origin-issuer-minimal.yaml
├── 20-secret-cfapi-token.enc.yaml
├── 21-originissuer.yaml
├── 30-configmap-origin-ca-pool.yaml
├── 40-configmap-cloudflared.yaml
├── 41-secret-cloudflared-credentials.enc.yaml
├── 42-deploy-cloudflared.yaml
├── 50-certificate-muppit.yaml
├── 60-hello-app.yaml
└── 70-networkpolicies.yaml
3 directories, 12 files
1.5.7 Add cloudflare to flux-config
In the cloudflare GitLab project → Settings → Repository → Deploy Keys → Privately accessible deploy keys
- Enable the same Flux deploy key you already use (
flux-ssh-auth). - When enabled it will appear under Enabled deploy keys.
On the Mac, navigate to the cloned flux-config project and pull any updates to ensure it is up to date:
cd ~/Projects/flux-config
git pull
1.5.8 Create manifests (plaintext)
Create the following files under clusters/my-cluster/cloudflare/.
1.5.8.1 Namespace (via Flux)
# clusters/my-cluster/cloudflare/ns/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: cloudflare
1.5.8.2 Kustomization
# clusters/my-cluster/cloudflare/00-kustomization-ns.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cloudflare-ns
namespace: flux-system
spec:
interval: 10m
prune: true
sourceRef:
kind: GitRepository
name: flux-system
namespace: flux-system
path: ./clusters/my-cluster/cloudflare/ns
timeout: 2m
1.5.8.3 Point Flux to your app repo
muppit-apps/cloudflare. Uses Deploy Key (flux-ssh-auth) and the SSH URL via git-ssh.reids.net.au.
# clusters/my-cluster/cloudflare/source.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: cloudflare-app
namespace: flux-system
spec:
interval: 5m
timeout: 60s
url: ssh://git-ssh.reids.net.au/muppit-apps/cloudflare.git
ref:
branch: main
secretRef:
name: flux-ssh-auth
1.5.8.4 Kustomization to apply the app repo
Decrypt SOPS.
Applies ./k8s/prod from the app repo, targets the cloudflare namespace, decrypts SOPS secrets using the sops-age secret in flux-system, and waits for the NS + origin-ca-issuer controller (so certificates can issue).
# clusters/my-cluster/cloudflare/10-kustomization-app.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cloudflare-app
namespace: flux-system
spec:
dependsOn:
- name: cloudflare-ns
- name: origin-ca-issuer-controller
interval: 10m
timeout: 4m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: cloudflare-app
namespace: flux-system
path: ./k8s/prod
targetNamespace: cloudflare
decryption:
provider: sops
secretRef:
name: sops-age
1.5.8.5 Add the default kustomizations
# clusters/my-cluster/cloudflare/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- source.yaml
- 00-kustomization-ns.yaml
- 10-kustomization-app.yaml
# clusters/my-cluster/cloudflare/ns/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
1.5.8.6 Ensure the root aggregator includes cloudflare/
Edit the aggregator clusters/my-cluster/kustomization.yaml and add cloudflare to the resources list.
# clusters/my-cluster/kustomization.yaml (in flux-config repo)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ./flux-system
- ./apps
- ./dev
# - ./prod
- ./origin-ca-issuer
- ./cloudflare
1.5.8.7 Check the cloudflare flux-config file structure
tree -a -I '.git|.DS_Store' clusters/my-cluster/cloudflare
clusters/my-cluster/cloudflare
├── .gitkeep
├── 00-kustomization-ns.yaml
├── 10-kustomization-app.yaml
├── kustomization.yaml
├── ns
│ ├── kustomization.yaml
│ └── namespace.yaml
└── source.yaml
2 directories, 7 files
1.5.9 Commit and push
From the flux-config repo on workstation:
git add clusters/my-cluster/{kustomization.yaml,cloudflare}
git commit -m "Add Flux source+kustomization for cloudflare app + NS"
git push
1.5.10 Reconcile and watch
-
Force reconciliation instead of waiting for the timer:
flux reconcile source git flux-system -n flux-system \
&& flux reconcile kustomization flux-system -n flux-system --with-source -
Check that all Kustomizations are applied and ready
flux get kustomizations -n flux-systemNAME REVISION SUSPENDED READY MESSAGE
app-source-kustomization main@sha1:2c4e91b2 False True Applied revision: main@sha1:2c4e91b2
cloudflare-app main@sha1:cc75d871 False True Applied revision: main@sha1:cc75d871
cloudflare-ns main@sha1:59e6cffb False True Applied revision: main@sha1:59e6cffb
coach-app-dev dev@sha1:9c3ba21c False True Applied revision: dev@sha1:9c3ba21c
flux-system main@sha1:59e6cffb False True Applied revision: main@sha1:59e6cffb
origin-ca-issuer-controller v0.12.1@sha1:86d908ed False True Applied revision: v0.12.1@sha1:86d908ed
origin-ca-issuer-crds v0.12.1@sha1:86d908ed False True Applied revision: v0.12.1@sha1:86d908ed
origin-ca-issuer-ns main@sha1:59e6cffb False True Applied revision: main@sha1:59e6cffb
origin-ca-issuer-rbac v0.12.1@sha1:86d908ed False True Applied revision: v0.12.1@sha1:86d908e -
Check the
cloudflarenamespace:kubectl get all -n cloudflareNAME READY STATUS RESTARTS AGE
pod/cloudflared-d869ccdc9-64zzn 1/1 Running 0 9m4s
pod/hello-app-859ddb5df-g74mj 1/1 Running 0 9m4s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/hello-service ClusterIP 10.50.70.51 <none> 80/TCP 9m4s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/cloudflared 1/1 1 1 9m4s
deployment.apps/hello-app 1/1 1 1 9m4s
NAME DESIRED CURRENT READY AGE
replicaset.apps/cloudflared-d869ccdc9 1 1 1 9m4s
replicaset.apps/hello-app-859ddb5df 1 1 1 9m4s -
Check the config maps and secret
kubectl -n cloudflare get cm,secretNAME DATA AGE
configmap/cf-origin-capool 1 31m
configmap/cloudflared 1 31m
configmap/kube-root-ca.crt 1 31m
NAME TYPE DATA AGE
secret/cfapi-token Opaque 1 31m
secret/cloudflared-credentials Opaque 1 31m
secret/muppit-au-origin-tls kubernetes.io/tls 3 31m -
Check the certificates
kubectl -n cloudflare get certificaterequests,certificateAME APPROVED DENIED READY ISSUER REQUESTER AGE
certificaterequest.cert-manager.io/muppit-au-origin-1 True True cf-origin system:serviceaccount:cert-manager:cert-manager 30m
NAME READY SECRET AGE
certificate.cert-manager.io/muppit-au-origin True muppit-au-origin-tls 30m -
Check from a workstation connected to the Internet, not LAN:
curl -I https://muppit.auHTTP/2 200
date: Sat, 11 Oct 2025 14:44:15 GMT
content-type: text/plain; charset=utf-8
content-length: 65
strict-transport-security: max-age=31536000; includeSubDomains
cf-cache-status: DYNAMIC
report-to: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=9iVUuR%2BDx9gSDC8F6yPUk%2BdkmY2c4uRdkUotkA7n2NibSWB%2F%2FrazzX73lWUY7d%2FYn1HugFnBWiCGDSYMGVqPDuig513qB7M%3D"}]}
nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
server: cloudflare
cf-ray: 98cf226b19858673-PER
alt-svc: h3=":443"; ma=86400curl https://muppit.auHello, world!
Version: 2.0.0
Hostname: hello-app-859ddb5df-g74mj
1.6 - Cloudflare DNS & Redirects
-
Ensure
muppit.auandwwwrecords exist and are proxied (orange cloud). -
If you want
www → rootredirect, Cloudflare has a ready template (“Redirect from WWW to root”).
If it warns thatwwwisn’t proxied, remove duplicate DNS entries and let the template create/manage thewwwCNAME + rule together. -
For the tunnel DNS route (optional CLI):
cloudflared tunnel route dns <TUNNEL_NAME> muppit.au
cloudflared tunnel route dns <TUNNEL_NAME> www.muppit.au
1.7 - Validate end‑to‑end
1.7.1 Config drift checks
kubectl diff -f k8s/prod/30-configmap-cloudflared.yaml # expect no diff if applied
1.7.2 Origin CA certificate health
kubectl -n cloudflare wait certificate/muppit-au-origin --for=condition=Ready --timeout=3m
kubectl -n cloudflare get secret muppit-au-origin-tls
Inspect cert:
kubectl -n cloudflare get secret muppit-au-origin-tls -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/muppit-origin.crt
openssl x509 -in /tmp/muppit-origin.crt -noout -subject -issuer -startdate -enddate -serial
1.7.3 TLS to ingress‑nginx with Origin CA
kubectl -n cloudflare get cm cf-origin-capool -o jsonpath='{.data.origin_ca\.pem}' > /tmp/origin_ca.pem
SVC_IP=$(kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.clusterIP}')
curl -sv --resolve muppit.au:443:$SVC_IP https://muppit.au/ --cacert /tmp/origin_ca.pem -o /dev/null
1.7.4 Cloudflared connectivity
kubectl -n cloudflare logs deploy/cloudflared --tail=100
kubectl -n cloudflare port-forward deploy/cloudflared 2000:2000 &
curl -s localhost:2000/metrics | head
1.7.5 Public check & redirect
curl -I https://muppit.au/
curl -I 'https://www.muppit.au/test?x=1' # expect 301 → https://muppit.au/test?x=1
1.8 - Troubleshooting snippets
- No cert issued but OriginIssuer Ready → check RBAC (controller must update
originissuers/statusandcertificaterequests/status) and the token Secret name/keys. - Ingress 404 → confirm ingress class, host, TLS secret name, and that
cloudflaredroutes to the right Service (ClusterIP + port 443) with correctoriginServerName. - NetworkPolicy gotchas → introducing a restrictive policy without allowing traffic from
ingress-nginxto app will break routing. Validate with a test pod in the nginx namespace hitting the service DNS name.
1.9 - Cleanup (full remove) — Flux‑first
Only follow this section if you want to completely remove
origin-ca-issuer. Prefer declarative cleanup: delete Git definitions and let Flux prune.
1.9.1 Remove Cloudflare stack
This will remove the Cloudflare stack.
- In infra repo: delete
clusters/my-cluster/cloudflare/{source.yaml,kustomization.yaml}or comment them out. - Commit & push, then:
flux reconcile kustomization flux-system -n flux-system --with-source - Verify resources in
cloudflarenamespace are pruned:kubectl get all -n cloudflare
1.9.2 Remove Origin CA Issuer
This will remove the Cloudflare Origin CA Issuer.
- In infra repo: delete files under
clusters/my-cluster/origin-ca-issuer/in this exact order (or remove the top‑levelkustomization.yamlresources):30-kustomization-controller.yaml20-kustomization-rbac.yaml10-kustomization-crds.yaml00-kustomization-ns.yamlsource.yaml
- Commit & push; reconcile Flux root:
flux reconcile kustomization flux-system -n flux-system --with-source - Confirm the controller namespace empties:
kubectl -n origin-ca-issuer get all || true
1.9.3 Manual emergency removal (only if needed)
Manual removal process.
# Remove Kustomizations and source
kubectl -n flux-system delete kustomization muppit-cloudflare origin-ca-issuer-{controller,rbac,crds,ns} --ignore-not-found
kubectl -n flux-system delete gitrepository muppit-cloudflare origin-ca-issuer-upstream --ignore-not-found
# Remove namespaces (and all contained objects)
kubectl delete ns cloudflare origin-ca-issuer --ignore-not-found
# RBAC leftovers
kubectl get clusterrole,clusterrolebinding | egrep -i 'origin(-|)ca(-|)issuer|cloudflare|cert-manager' || true
# Delete any stray items you don’t want:
# kubectl delete clusterrole <NAME>
# kubectl delete clusterrolebinding <NAME>
1.10 - Operations (rotations & routine tasks)
Rotate Origin CA certificate (on demand)
kubectl -n cloudflare delete secret muppit-au-origin-tls
kubectl -n cloudflare wait certificate/muppit-au-origin --for=condition=Ready --timeout=3m
Update tunnel config
- Edit
k8s/prod/40-configmap-cloudflared.yaml(or credentials Secret), commit → Flux rolls the Deployment.
Add another site
- Add DNS/route in Cloudflare (proxied or
cloudflared tunnel route dns <tunnel> <host>). - Add new
Certificate+Ingress(and optional NetPol) ink8s/prod/. - Commit/push; verify with SNI curl against ingress ClusterIP.
age key rotation (no downtime)
- Generate a new keypair:
age-keygen -o ~/.sops/age-new.key
age-keygen -y ~/.sops/age-new.key # copy the new public key
- Add new public key to
.sops.yamlalongside the old one; re‑encrypt all:
find k8s/prod -name '*.enc.yaml' -print0 | xargs -0 -n1 sops --encrypt --in-place
git commit -am "SOPS: add new recipient" && git push
- Update Flux secret to include both private keys:
cat ~/.sops/age.key ~/.sops/age-new.key > /tmp/age-rotating.keys
kubectl -n flux-system create secret generic sops-age \
--from-file=age.agekey=/tmp/age-rotating.keys \
-o yaml --dry-run=client | kubectl apply -f -
- Remove old recipient from
.sops.yaml, re‑encrypt all again, and update Flux secret to contain only the new key.
Rotate Cloudflare API token
- Create a new token in Cloudflare (same scope).
sops k8s/prod/20-secret-cfapi-token.enc.yaml→ replace value → save → commit/push.- Check:
kubectl -n cloudflare get secret cfapi-token -o yaml | grep -E 'name:|namespace:' -n
kubectl -n cloudflare get originissuer cf-origin -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
Rotate cloudflared tunnel credentials
- Replace JSON in
k8s/prod/41-secret-cloudflared-credentials.enc.yamlviasops. - Commit/push; check
kubectl -n cloudflare logs deploy/cloudflared --since=3m.
Hygiene
kubectl diff -f <file-or-dir>before manual appliesflux get kustomizations -Aandflux events -A --since=1hkubectl -n cloudflare get secretsto audit what exists
1.11 - Useful commands
Check Flux trees
flux get kustomizations -A
flux tree kustomization flux-system -n flux-system
Diff any manifest against live
kubectl diff -f path/to/file.yaml
Find latest cloudflared tag (calendar‑versioned)
crane ls cloudflare/cloudflared | grep -E '^[0-9]{4}\.[0-9]+(\.[0-9]+)?$' | sort -V | tail -n1
Pin Origin CA Issuer to a new tag
vi clusters/my-cluster/origin-ca-issuer/source.yaml # change spec.ref.tag:
git add . && git commit -m "bump origin-ca-issuer to vX.Y.Z" && git push
flux reconcile source git origin-ca-issuer-upstream -n flux-system && \
flux reconcile kustomization origin-ca-issuer-crds -n flux-system && \
flux reconcile kustomization origin-ca-issuer-rbac -n flux-system && \
flux reconcile kustomization origin-ca-issuer-controller -n flux-system
Repo layout preview
tree -a -I '.git|.DS_Store'
Cloudflare redirect test
curl -I 'https://www.muppit.au/test?x=1'
# Expect: HTTP/2 301 with Location: https://muppit.au/test?x=1