Files
home-network-k3s-impl/platform/cloudflared/deployment.yaml
jakman85andClaude Opus 5.5 d9ab57f2d1
gate / gate (pull_request) Successful in 42s
gate / conformance (pull_request) Skipped
fix(cloudflared): fsGroup so uid 65532 can read the 0440 credentials Secret
The pod ran as 65532:65532 with no fsGroup, so the kubelet laid the Secret's
credentials.json down root:root 0440 — unreadable to the container. cloudflared
logged "permission denied" and then the red-herring "Error locating origin cert".

- deployment.yaml: securityContext.fsGroup 65532 (mode stays 0440)
- L0 cloudflared-ingress-rules (7): Secret volumes must be readable by the
  container's run-as uid/groups; mutation self-tested
- runbook: permission-denied troubleshooting entry, credential rotation section
- decisions/2026-11-08-cloudflared-creds-permission.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:49:56 -06:00

151 lines
6.2 KiB
YAML

---
# cloudflared — the Cloudflare Tunnel connector. Outbound-only: it dials the Cloudflare edge on
# 7844 (QUIC, falling back to HTTP/2 over TCP) and carries requests for the tunnel's hostnames
# back to the shared Gateway. Nothing listens on the LAN and no router port is opened.
#
# HA: 2 replicas, hard-spread across nodes. Each replica registers its own set of edge
# connections for the SAME tunnel, so Cloudflare load-balances between them and losing one node
# (a kured reboot, a drain) loses no traffic. The PDB in this directory keeps a drain from taking
# both at once.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
labels:
app.kubernetes.io/name: cloudflared
spec:
replicas: 2
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
# Never drop below the running count during a rollout; with 3 nodes and a hard spread the
# surge pod always has a node to land on.
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: cloudflared
template:
metadata:
labels:
app.kubernetes.io/name: cloudflared
# internet, NEVER vpn. The vpn class steers egress through the PIA tunnel (ADR-0015), which
# would put the edge connections behind a third party's exit and its kill-switch — the
# tunnel would drop whenever PIA does. This namespace is outside the app-namespace scope
# of require-egress-class-label, so the label is declared for the steering, not to pass
# admission; the L0 check cloudflared-ingress-rules pins it.
homelab.keithjackson.dev/egress-class: internet
spec:
# cloudflared never talks to the Kubernetes API. No token mounted, and the egress policy
# does not admit the kube-apiserver entity either.
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
runAsNonRoot: true
# The image is distroless `nonroot` (65532).
runAsUser: 65532
runAsGroup: 65532
# fsGroup makes the kubelet GROUP-own the Secret volume's files to 65532. Without it they
# are root:root, and defaultMode 0440 (below) is unreadable to uid 65532 — every replica
# CrashLooped on "couldn't read tunnel credentials ... permission denied" (2026-11-08),
# which cloudflared then misreports as "Error locating origin cert". Same lesson as
# platform/keycloak/client-secret-reconciler.yaml. fsGroup + 0440 keeps the credential
# unreadable to any other uid rather than relaxing it to world-readable 0444.
# Pinned by the L0 check cloudflared-ingress-rules, assertion (7).
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: cloudflared
# cloudflared drains in-flight requests for up to its own 30s grace-period on SIGTERM.
terminationGracePeriodSeconds: 45
containers:
- name: cloudflared
# Tag + digest: Renovate's `kubernetes` manager (renovate.json5, platform/**) bumps both.
image: docker.io/cloudflare/cloudflared:2026.10.7@sha256:4955524aeaa379e154023d5a027c250f7b35041a2e7e950cf13f144bc9b9c5ba
# The image ENTRYPOINT is `cloudflared --no-autoupdate`; these replace its CMD. The tunnel
# UUID, credentials path, metrics address and ingress rules all come from the file.
args:
- tunnel
- --config
- /etc/cloudflared/config/config.yaml
- run
ports:
- name: metrics
containerPort: 2000
protocol: TCP
# /ready is 200 only while this replica holds at least one live edge connection, so an
# unenrolled or edge-isolated replica is NotReady and the Deployment says so.
readinessProbe:
httpGet:
path: /ready
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
# Liveness is deliberately NOT /ready: an edge outage would otherwise restart-loop every
# replica, which reconnects nothing faster and churns the pods. /healthcheck answers
# whenever the process is serving.
livenessProbe:
httpGet:
path: /healthcheck
port: metrics
initialDelaySeconds: 10
periodSeconds: 20
failureThreshold: 3
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: config
mountPath: /etc/cloudflared/config
readOnly: true
- name: creds
mountPath: /etc/cloudflared/creds
readOnly: true
volumes:
- name: config
configMap:
# Rewritten to the content-hashed name by kustomize (configMapGenerator).
name: cloudflared-config
- name: creds
secret:
# cloudflared-credentials.sops.yaml — the tunnel credentials JSON, by name only.
secretName: cloudflared-credentials
# 0440, owner root, group fsGroup 65532 (pod securityContext) — readable by the
# container user through its group, by nobody else.
defaultMode: 0440
---
# One replica may be disrupted at a time — enough for kured's one-node-at-a-time drain to
# proceed, never enough to take the tunnel down. minAvailable 1 (not 2) on purpose: a 0-disruption
# PDB wedges a drain (platform/kured/alerts.yaml, KuredRebootPending).
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: cloudflared
namespace: cloudflared
labels:
app.kubernetes.io/name: cloudflared
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: cloudflared