Workflow: Only What It Needs
What Each Setting Takes Away - and What It Does Not
Assume an attacker runs commands as the app’s process. Each setting removes something from them; none of them makes the process harmless.
| Setting | What it takes away | What it does not |
|---|---|---|
runAsNonRoot: true, runAsUser: 10001 | root inside the container: changing files that root owns, the privileges many exploits assume | the app’s own rights - it still reads what the app can read, including its Secrets |
readOnlyRootFilesystem: true | changing the image: no replaced binaries, no tools installed into it, no persistence in its paths; every writable path must be declared | running code - a writable /tmp and an interpreter in the image are still there |
allowPrivilegeEscalation: false | gaining privileges through setuid/setgid programs and file capabilities (the kernel’s no_new_privs) | a kernel or runtime vulnerability |
capabilities: drop: ["ALL"] | the special rights root normally keeps: raw sockets (spoofing, scanning), changing file ownership, binding low ports, … | anything the process may do with ordinary permissions |
seccompProfile: RuntimeDefault | system calls a normal app never makes - a smaller kernel surface to attack | the system calls the app does make |
automountServiceAccountToken: false | the service account token that Kubernetes would otherwise mount into /var/run/secrets/kubernetes.io/serviceaccount | other credentials the app is given some other way - and the apps never call the API anyway |
enableServiceLinks: false | the variables Kubernetes adds for every Service in the namespace (BACKEND_SERVICE_HOST, BACKEND_PORT=tcp://10.96.2.210:80, …): none of them reaches the app. One of them had the same name as a frontend setting and would have stopped every new frontend pod at start (caught on kind before it shipped) | finding Services - names resolve through DNS (backend), and the API server’s own variables (KUBERNETES_SERVICE_HOST, KUBERNETES_SERVICE_PORT, KUBERNETES_SERVICE_PORT_HTTPS, KUBERNETES_PORT=tcp://..., KUBERNETES_PORT_443_TCP_*, for the kubernetes Service in default) are added either way |
emptyDir with sizeLimit | an unbounded writable path: the kubelet measures the volume on a schedule and evicts the pod once it is over its limit | a hard stop - it is not a quota: a very fast writer can fill the node’s disk before the next measurement, and then other pods feel the disk pressure too |
Root in a container is not root on the node. Namespaces, cgroups, seccomp and the runtime separate them. Getting out takes a vulnerability in the kernel or the runtime - or a setting that opens the way: privileged: true, a hostPath mount, the host’s network or process namespace, added capabilities. The hardening removes those settings and the tools that make an escape easier, and it limits what a taken-over process can do inside. It does not guarantee containment: it makes the attacker’s job longer, louder and narrower.
Three Layers
The check in the pull request (CI). scripts/check-app-security.sh renders every app overlay and refuses a container without the settings above, a Deployment with a service account token, and an emptyDir without a sizeLimit. It also refuses service links on every workload written under flux/ - the apps now, and the platform’s own as later chapters add them (pods that Helm charts and operators create are theirs to configure). It runs in pre-commit and again in CI (Chapter 05) - the earliest place a regression is stopped.
check-app-security.sh
Show the app security check
#!/usr/bin/env bash
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
for tool in kubectl yq jq flux; do
command -v "${tool}" >/dev/null || { echo "check-app-security: ${tool} is required" >&2; exit 1; }
done
failures=()
MIN_PRESTOP=5 # seconds; on kind 10 s did no better than 5 s
MIN_SHUTDOWN=10 # seconds the app needs after the sleep: the backend's graceful shutdown timeout (cmd/api/main.go)
for overlay in "${overlays[@]}"; do
# Render the overlay exactly as Flux would build it; every rule below reads this output.
if ! rendered="$(kubectl kustomize "${overlay}" 2>&1)"; then
failures+=("${overlay}: kustomize build failed: ${rendered}")
continue
fi
# Rule: automountServiceAccountToken: false on every Deployment - and count the Deployments,
# so an overlay that renders none fails instead of passing with nothing checked.
deployments=0
while IFS=$'\t' read -r name automount; do
[[ -z "${name}" ]] && continue
deployments=$((deployments + 1))
if [[ "${automount}" != "false" ]]; then
failures+=("${overlay}: Deployment ${name} must set automountServiceAccountToken: false (got: ${automount})")
fi
done < <(yq 'select(.kind == "Deployment") | [.metadata.name, (.spec.template.spec.automountServiceAccountToken | tostring)] | @tsv' <<<"${rendered}")
if ((deployments == 0)); then
failures+=("${overlay}: renders no Deployment - nothing was checked")
fi
# One line per container: deployment, container, runAsNonRoot (container, pod), readOnlyRootFilesystem,
# allowPrivilegeEscalation, capabilities.drop, seccomp type (container, pod). Both levels are printed
# and decided here: yq's // would treat an explicit false on the container as unset.
# shellcheck disable=SC2016 # $pod and $d below are yq variables, not shell ones
while IFS=$'\t' read -r dep ctr nonroot_c nonroot_p rofs ape drop seccomp_c seccomp_p; do
[[ -z "${ctr}" ]] && continue
where="${overlay}: Deployment ${dep} container ${ctr}"
nonroot="${nonroot_c}"; [[ "${nonroot}" == "null" ]] && nonroot="${nonroot_p}"
seccomp="${seccomp_c}"; [[ "${seccomp}" == "null" ]] && seccomp="${seccomp_p}"
[[ "${nonroot}" == "true" ]] || failures+=("${where}: runAsNonRoot must be true (got: ${nonroot})")
[[ "${rofs}" == "true" ]] || failures+=("${where}: readOnlyRootFilesystem must be true (got: ${rofs})")
[[ "${ape}" == "false" ]] || failures+=("${where}: allowPrivilegeEscalation must be false (got: ${ape})")
[[ ",${drop}," == *",ALL,"* ]] || failures+=("${where}: capabilities.drop must include ALL (got: '${drop}')")
[[ "${seccomp}" == "RuntimeDefault" || "${seccomp}" == "Localhost" ]] ||
failures+=("${where}: seccompProfile must be RuntimeDefault (got: ${seccomp})")
done < <(yq 'select(.kind == "Deployment") | .spec.template.spec as $pod | .metadata.name as $d
| ($pod.containers + ($pod.initContainers // []))[]
| [$d, .name,
(.securityContext.runAsNonRoot | tostring), ($pod.securityContext.runAsNonRoot | tostring),
(.securityContext.readOnlyRootFilesystem | tostring),
(.securityContext.allowPrivilegeEscalation | tostring),
((.securityContext.capabilities.drop // []) | join(",")),
(.securityContext.seccompProfile.type | tostring), ($pod.securityContext.seccompProfile.type | tostring)]
| @tsv' <<<"${rendered}")
# Rule: every app container sleeps MIN_PRESTOP seconds or more before it is stopped, and the
# grace period (default 30 s) leaves room for that sleep plus the app's own shutdown.
while IFS=$'\t' read -r dep container sleep grace; do
[[ -n "${dep}" && "${dep}" != "---" ]] || continue # yq separates the documents with ---
where="${overlay}: Deployment ${dep} container ${container}"
if [[ ! "${grace}" =~ ^[0-9]+$ ]]; then
failures+=("${where}: terminationGracePeriodSeconds must be a whole number of seconds (got: ${grace})")
continue
fi
if [[ ! "${sleep}" =~ ^[0-9]+$ ]] || ((sleep < MIN_PRESTOP)); then
failures+=("${where}: lifecycle.preStop.sleep.seconds must be at least ${MIN_PRESTOP} (got: ${sleep})")
elif ((grace < sleep + MIN_SHUTDOWN)); then
failures+=("${where}: terminationGracePeriodSeconds (${grace}s) must cover the preStop sleep (${sleep}s) plus ${MIN_SHUTDOWN}s of shutdown")
fi
done < <(yq 'select(.kind == "Deployment") | .metadata.name as $d
| (.spec.template.spec.terminationGracePeriodSeconds // 30) as $g
| .spec.template.spec.containers[]
| [$d, .name, (.lifecycle.preStop.sleep.seconds // "none" | tostring), ($g | tostring)] | @tsv' <<<"${rendered}")
# Rule: every emptyDir has a sizeLimit - a writable path is the price of a read-only root
# filesystem, and it must not be able to fill the node's disk.
# shellcheck disable=SC2016 # $d below is a yq variable, not a shell one
while IFS=$'\t' read -r dep vol limit; do
[[ -z "${vol}" ]] && continue
if [[ "${limit}" == "null" || -z "${limit}" ]]; then
failures+=("${overlay}: Deployment ${dep} emptyDir volume ${vol} must set a sizeLimit")
fi
done < <(yq 'select(.kind == "Deployment") | .metadata.name as $d
| (.spec.template.spec.volumes // [])[] | select(has("emptyDir"))
| [$d, .name, (.emptyDir.sizeLimit | tostring)] | @tsv' <<<"${rendered}")
# Rule: for every Deployment an HPA scales, no spec.replicas in Git.
while IFS= read -r target; do
[[ -z "${target}" ]] && continue
replicas="$(yq "select(.kind == \"Deployment\" and .metadata.name == \"${target}\") | .spec.replicas" <<<"${rendered}")"
if [[ -n "${replicas}" && "${replicas}" != "null" ]]; then
failures+=("${overlay}: Deployment ${target} has an HPA and must not set spec.replicas (got: ${replicas})")
fi
done < <(yq 'select(.kind == "HorizontalPodAutoscaler" and .spec.scaleTargetRef.kind == "Deployment") | .spec.scaleTargetRef.name' <<<"${rendered}")
# Rule: no injected errors in production, in any container of either app.
if [[ "${overlay}" == */production ]]; then
# shellcheck disable=SC2016 # $d below is a yq variable, not a shell one
while IFS=$'\t' read -r dep container; do
[[ -z "${dep}" ]] && continue
failures+=("${overlay}: Deployment ${dep} container ${container} must not set RANDOM_ERROR_RATE in production")
done < <(yq -N 'select(.kind == "Deployment") | .metadata.name as $d
| ((.spec.template.spec.containers // []) + (.spec.template.spec.initContainers // []))[]
| select((.env // [])[] | .name == "RANDOM_ERROR_RATE")
| [$d, .name] | @tsv' <<<"${rendered}")
fi
# Rules for the backend only: PPROF_ENABLED off, CHAOS_ENABLED as expected for the environment.
if [[ "${overlay}" == flux/apps/backend/* ]]; then
pprof="$(backend_env "${rendered}" PPROF_ENABLED)"
chaos="$(backend_env "${rendered}" CHAOS_ENABLED)"
if [[ "${pprof}" != "false" ]]; then
failures+=("${overlay}: backend PPROF_ENABLED must be \"false\" in Git (got: '${pprof}')")
fi
expected="$(expected_chaos_for "${overlay}")"
if [[ -z "${expected}" ]]; then
failures+=("${overlay}: no expected CHAOS_ENABLED value - add the overlay to expected_chaos_for")
elif [[ "${chaos}" != "${expected}" ]]; then
failures+=("${overlay}: backend CHAOS_ENABLED must be \"${expected}\" (got: '${chaos}')")
fi
fi
done
for profile in flux/bootstrap/profiles/*/; do
if ! kubectl kustomize --load-restrictor=LoadRestrictionsNone "${profile}" >"${render_tmp}/profile.yaml" 2>"${render_tmp}/err"; then
failures+=("${profile}: kustomize build failed: $(cat "${render_tmp}/err")")
continue
fi
enqueue "${render_tmp}/profile.yaml" "${profile}"
done
builds=0
next=0
while ((next < ${#pending[@]})); do
file="${pending[next]}"
next=$((next + 1))
# Name, path (Flux's default: the source root) and source as kind/namespace/name - the source's
# namespace defaults to the Kustomization's own.
if ! IFS=$'\t' read -r name source path < <(yq -N '[.metadata.name,
.spec.sourceRef.kind + "/" + (.spec.sourceRef.namespace // .metadata.namespace // "flux-system") + "/" + .spec.sourceRef.name,
.spec.path // "./"] | @tsv' "${file}"); then
failures+=("${file##*/}: a Flux Kustomization yq cannot read")
continue
fi
if [[ "${source}" != "GitRepository/flux-system/flux-system" ]]; then
if [[ " ${FOREIGN_SOURCES[*]} " != *" ${name} "* ]]; then
failures+=("Flux Kustomization ${name}: its source ${source} is not this repository, so it cannot be built here - if it deploys no workload of ours, add it to FOREIGN_SOURCES")
fi
continue
fi
builds=$((builds + 1))
if ! flux build kustomization "${name}" --path "${path}" --kustomization-file "${file}" \
--dry-run >"${render_tmp}/built.yaml" 2>"${render_tmp}/err"; then
failures+=("Flux Kustomization ${name} (${path}): flux build failed: $(cat "${render_tmp}/err")")
continue
fi
collect_links "Flux Kustomization ${name} (${path})" "${render_tmp}/built.yaml"
enqueue "${render_tmp}/built.yaml" "Flux Kustomization ${name} (${path})"
done
workloads="$(cut -f1,2 "${links_rows}" | sort -u | grep -c . || true)"
if ((${#failures[@]} > 0)); then
echo "check-app-security: ${#failures[@]} problem(s):" >&2
printf ' - %s\n' "${failures[@]}" >&2
exit 1
fi
echo "check-app-security: OK (${#overlays[@]} overlays; ${builds} Flux Kustomizations built, ${workloads} workloads; ${dirs} kustomize directories rendered; ${documents} workload documents checked as written; ${ip_rules} NetworkPolicy ipBlock rules, both families)"
Pod Security at admission (cluster). The three environment namespaces carry pod-security.kubernetes.io/enforce: restricted: the API server refuses a pod that runs as root, allows privilege escalation, keeps capabilities, has no seccomp profile, or asks for host access. Two details matter:
- It refuses pods, not Deployments. The namespaces also carry
warn: restricted. Warn and audit both evaluate a Deployment’s pod template: warn returnsWarning: would violate PodSecurityto whoever applied it, audit records the violation as an annotation in the API server’s audit log. Only enforce refuses - and enforce checks the pods. With Pod Security alone, a Deployment that asks for root is accepted with that warning, and its ReplicaSet then fails to create every pod. The refusal is in the ReplicaSet’s events. (On this platform Kyverno’s guardrail refuses such a Deployment first, Chapter 16; Pod Security is the layer underneath, and the one every cluster has.) - Restricted is not the platform’s whole baseline. It does not require a read-only root filesystem, nor a disabled service account token, nor size limits - those come from the check in the pull request.
Namespaces with Pod Security labels
Show namespaces.yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: auth
labels:
name: auth
managed-by: flux
---
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager
labels:
name: cert-manager
managed-by: flux
---
# Traefik, the ingress (flux/infrastructure/ingress/traefik). The application network policies admit
# traffic from this namespace by its name.
apiVersion: v1
kind: Namespace
metadata:
name: traefik
labels:
name: traefik
managed-by: flux
---
# external-dns is its own tool (DNS records for Ingresses), not part of cert-manager (TLS).
apiVersion: v1
kind: Namespace
metadata:
name: external-dns
labels:
name: external-dns
managed-by: flux
---
apiVersion: v1
kind: Namespace
metadata:
annotations:
# A Namespace must never be garbage-collected by Flux, even if another
# Kustomization once applied it (see the duplicate namespace.yaml files).
kustomize.toolkit.fluxcd.io/prune: Disabled
name: develop
labels:
name: develop
environment: development
managed-by: flux
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
---
apiVersion: v1
kind: Namespace
metadata:
annotations:
# A Namespace must never be garbage-collected by Flux, even if another
# Kustomization once applied it (see the duplicate namespace.yaml files).
kustomize.toolkit.fluxcd.io/prune: Disabled
name: staging
labels:
name: staging
environment: staging
managed-by: flux
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
---
apiVersion: v1
kind: Namespace
metadata:
annotations:
# A Namespace must never be garbage-collected by Flux, even if another
# Kustomization once applied it (see the duplicate namespace.yaml files).
kustomize.toolkit.fluxcd.io/prune: Disabled
name: production
labels:
name: production
environment: production
managed-by: flux
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
This is also why the labs use scripts/lab-pod.sh: a plain kubectl run in develop is refused - it has none of these settings. The script adds them, so a probe pod is admitted like an app.
The running container (cluster). The settings take effect in the pod. The pod’s securityContext holds what applies to every container (the user, the group, fsGroup, seccomp); each container’s holds the rest (read-only root, no escalation, capabilities). Where both set the same field, the container wins.
Backend Deployment (security context and volumes)
Show the backend Deployment
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels:
app: backend
app.kubernetes.io/name: backend
app.kubernetes.io/component: api
spec:
# No spec.replicas: the HorizontalPodAutoscaler owns the replica count (hpa.yaml per environment,
# minReplicas is the floor). A replicas value in Git makes Flux reset what the HPA scaled on every
# reconcile. Existing clusters: run scripts/hpa-replicas-handover.sh once before this change.
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
app.kubernetes.io/name: backend
app.kubernetes.io/component: api
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
# No <SERVICE>_PORT=tcp://... variables for the namespace's Services; KUBERNETES_SERVICE_* stay
# (scripts/check-app-security.sh says why).
enableServiceLinks: false
# The backend never calls the Kubernetes API: no ServiceAccount token in the pod.
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
# Schema migrations before the app (Chapter 18): the same image reference as the app (the
# overlay's images: transformer sets both tags; Kyverno verifies the digest each tag resolves to
# at admission - a tag is not pinned, see Chapter 17) runs `backend migrate`, applies the migrations
# embedded in it that the database lacks (golang-migrate, under a Postgres advisory lock) and exits.
# It runs whenever a backend pod is created - rollout, reschedule, scale-out; with nothing pending
# it only checks. The recovery point is the continuous WAL archive (Chapter 12), not a backup taken
# here; a migration's pull request is merged only while that archive is healthy. The app then only
# checks the schema version and refuses to start on one it cannot use. Migrations only expand:
# the previous release keeps working on the new schema, so rolling back the image never needs
# rolling back the data.
initContainers:
- name: migrate
image: backend:latest # name/tag set by the environment overlay (images: transformer)
imagePullPolicy: IfNotPresent
args: ["migrate"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
capabilities:
drop:
- ALL
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: app-postgres-app
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: app-postgres-app
key: password
- name: POSTGRES_HOST
value: app-postgres-rw
- name: POSTGRES_DB
value: app
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: backend
image: backend:latest # name/tag set by the environment overlay (images: transformer)
imagePullPolicy: IfNotPresent
# Stopping, the pod is removed from the Service's endpoints and from Traefik - but not at once,
# while the container already gets SIGTERM. The sleep keeps it serving until they have caught
# up; then the app shuts down. Measured on kind (a rollout, ~115 requests/s): without it 14-25
# failed requests (refused, or 502 from Traefik), with it 0-3 (Chapter 20). The kubelet's own
# sleep - no shell in the image needed. 5 s + the app's shutdown fit in the 30 s grace period.
lifecycle:
preStop:
sleep:
seconds: 5
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
capabilities:
drop:
- ALL
ports:
- containerPort: 8080
name: http
protocol: TCP
env:
- name: PORT
value: "8080"
- name: NAMESPACE
value: "${NAMESPACE}"
- name: ENVIRONMENT
value: "${ENVIRONMENT}"
- name: LOG_LEVEL
value: "${LOG_LEVEL}"
- name: SERVICE_NAME
value: "backend"
# No SERVICE_VERSION: the backend reports the version baked into the image (the same as
# /version), so every span says which release produced it.
- name: DEPLOYMENT_ENVIRONMENT
value: "${ENVIRONMENT}"
# Where a span came from: the cluster, the namespace (= the environment) and the pod - set from
# the downward API, so traces can be filtered per environment and per pod.
- name: K8S_NAMESPACE_NAME
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: K8S_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: OTEL_RESOURCE_ATTRIBUTES
value: "k8s.cluster.name=${cluster_name},k8s.namespace.name=$(K8S_NAMESPACE_NAME),k8s.pod.name=$(K8S_POD_NAME)"
- name: UPTRACE_DSN
valueFrom:
secretKeyRef:
name: backend-secrets
key: uptrace-dsn
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: backend-secrets
key: jwt-secret
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: app-postgres-app
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: app-postgres-app
key: password
- name: POSTGRES_HOST
value: app-postgres-rw
- name: POSTGRES_DB
value: app
# Security switches (backend >= the 2026-09-27 hardening). Safe values here; an environment
# overlay may only turn chaos on (develop, staging). scripts/check-app-security.sh enforces it.
- name: CHAOS_ENABLED # /panic + readiness/liveness toggles (still need a token)
value: "false"
- name: PPROF_ENABLED # profiling listener on loopback; never on in Git
value: "false"
- name: AUTH_REGISTRATION_ENABLED
value: "true"
livenessProbe:
httpGet:
path: /livez
port: http
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 0
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
# The production values (staging gets the same; develop patches its own). Requests are what
# the scheduler reserves and the quota counts; limits are the ceiling: memory over the limit
# is OOMKilled, CPU over it is throttled. requests < limits makes the pod Burstable.
resources:
requests:
cpu: 100m
memory: 128Mi
ephemeral-storage: 64Mi
limits:
cpu: 250m
memory: 256Mi
ephemeral-storage: 128Mi
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /home/app/.cache
volumes:
# Every writable path is an emptyDir with a size limit. It is not a quota that stops the write:
# the kubelet measures the volume on a schedule and evicts the pod once it is over the limit -
# a very fast writer can still fill the node's disk before that. It bounds a slow leak and makes
# the pod's budget explicit. /tmp holds request bodies Go buffers to disk - measured at 0 KB at
# rest, so 64Mi leaves room for uploads.
- name: tmp
emptyDir:
sizeLimit: 64Mi
- name: cache
emptyDir:
sizeLimit: 10Mi
The User Is Not a Random Number
runAsUser: 10001 is not chosen for being “not root”. The backend’s and the frontend’s images create a user with UID 10001 and give it its own directories (/home/app/.cache, /var/cache/nginx). The Deployment runs the process as that user, so it owns what it must write.
runAsUser- the process’s UID;runAsGroup- its primary group;fsGroup- the group a mounted volume is given, so the process can write to it even when the volume did not start out as its own.
An arbitrary UID such as 1000 passes Pod Security just as well - and then fails on the first file the image gave to 10001.
Two Errors, Two Fixes
| The error | What it means | The fix |
|---|---|---|
Read-only file system (EROFS, error 30) | the write went to the read-only root filesystem | mount a writable volume at that path: an emptyDir with a sizeLimit |
Permission denied (EACCES, error 13) | the filesystem is writable, the user is not allowed: wrong owner or mode | a path the app’s user owns - in the image, or a volume with the right fsGroup |
Neither is fixed with root. A read-only filesystem is read-only for root too: EROFS stays. EACCES is subtler: root in this container has no capabilities (drop: ["ALL"]), so without CAP_DAC_OVERRIDE it follows the ordinary owner/group/other permission bits like any other user. B3’s /etc is owned by root with mode 755: root may write there, UID 65532 falls under “other” (r-x) and gets Permission denied (tested on kind). So root would “fix” B3’s /etc - by giving the process the image’s own files, which is exactly what the non-root user was there to prevent. Pod Security refuses it anyway; the answer is a path the app’s user owns.
Guardrails That Stop It
check-app-security.sh(workstation, CI) - the full baseline, in the pull request.- Pod Security restricted (cluster) - refuses unhardened pods in the three environments, whatever the manifest says.
- Flux (cluster) - a Deployment changed by hand is set back at the next reconcile (Chapter 03).
- RBAC (cluster) - on Hetzner the everyday sign-in cannot change a namespace’s Pod Security labels: namespaces are cluster-wide objects, and its rights end inside
develop(Chapter 01).
Safe Workflow (Step-by-Step)
- Read the error:
Read-only file systemorPermission denied- and which path. - Find the declared paths: the container’s
volumeMounts. - Add exactly the missing path in Git: an
emptyDirwith asizeLimit, or ownership in the image for a permission error. - Pull request:
hooksruns the check; read it. - Prove it on the running pod:
idis not 0, the app writes where it should, and a write elsewhere still fails withRead-only file system.
Instructions for Your AI Agent
The rules this chapter adds:
- Never weaken a security setting to make an app start - no root, no
privileged, no added capabilities, no writable root filesystem. Find the path the app needs and add a writable volume for it. Enforced by: Pod Security restricted indevelop,stagingandproduction, at admission;scripts/check-app-security.shin pre-commit and CI. - Tell “Read-only file system” from “Permission denied” - the first needs a writable mount, the second a path the app’s user owns. Neither is fixed with root. Enforced by: instruction only.
- Every
emptyDirgets asizeLimit. Enforced by:scripts/check-app-security.sh. - Never set
automountServiceAccountToken: truefor an app that does not call the Kubernetes API. Enforced by:scripts/check-app-security.sh. - Every workload you write sets
enableServiceLinks: false, and no patch sets it true or removes it - no variables for the namespace’s Services reach the pod (the API server’sKUBERNETES_SERVICE_HOST,_PORT,_PORT_HTTPS,KUBERNETES_PORTandKUBERNETES_PORT_*still do); it finds Services through DNS. Enforced by:scripts/check-app-security.sh. - Never change a namespace’s Pod Security level (
pod-security.kubernetes.io/enforce) to get a pod in. Enforced by: on Hetzner the OIDC roles cannot write namespaces, and the labels are in Git; on kind - instruction only.
The full list, chapter by chapter, is docs/agent-rules.md in your fork of the platform repository.
This builds on: Network Policies (Chapter 06) - the network limits where a pod may connect; the security context limits what it may do inside. This enables: Resource Management (Chapter 08) - after what a container may do comes how much it may use.