Kubernetes Security Best Practices for Production
Harden Kubernetes: RBAC, Pod Security Admission, non-root read-only pods, NetworkPolicies, admission policies, encrypted Secrets and signed images.
TLDR: Secure Kubernetes in layers. Give people and pipelines only the RBAC permissions they need, with short-lived tokens. Enforce the restricted Pod Security Standard on app namespaces, so pods run as non-root, with no extra Linux capabilities and a seccomp profile. Deny network traffic by default with NetworkPolicies and allow only what the app needs. Add your own rules with ValidatingAdmissionPolicies. Encrypt Secrets at rest, run images you can trace to a signed build, and check the cluster against the CIS benchmark.
In this part you apply each layer to the guide's app and see it work: a pod that Kubernetes refuses to start, a kubectl run that the namespace rejects, a connection a NetworkPolicy drops, an image tag an admission policy blocks, a Secret read straight out of etcd, and a kube-bench report. It ends with a checklist for production.
Prerequisites
- The app after Part 8 (podinfo, the cache StatefulSet and the
podinfo-tlsSecret), the Gateway and HTTPRoute from Part 5 with cloud-provider-kind running, and the monitoring stack from Part 9. This part does not need the HorizontalPodAutoscaler; the run shown here deleted it. cosign3.x for the image signature check (optional).
Security in Layers
No layer is enough on its own. RBAC does not stop a compromised pod from attacking its neighbors, a NetworkPolicy does not stop someone with create pods rights from mounting every Secret, and Pod Security does not check where an image came from. Together they make each mistake smaller.
Authentication and RBAC
Kubernetes has no user database. People authenticate with client certificates or, better, OIDC tokens from your identity provider (managed services connect this to their IAM), and workloads authenticate as ServiceAccounts. Check who you are:
kubectl auth whoami
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [kubeadm:cluster-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id [X509SHA256=a7a9a6330b9c9c12628585e1bd05e7480667b6ee2fa16dc6f69042e850e5a5be]
kind's admin kubeconfig holds a client certificate for the kubeadm:cluster-admins group, which can do everything. Never share a certificate like this: it is valid for a year and Kubernetes cannot revoke a single client certificate.
RBAC grants permissions with four objects. A Role (namespaced) or ClusterRole (cluster-wide) lists allowed verbs on resources, and a RoleBinding or ClusterRoleBinding gives a role to users, groups or ServiceAccounts. Everything not granted is denied. This file creates two ServiceAccounts: one for podinfo with no permissions at all, and one for a deployment pipeline that may update Deployments and read pods in demo, and nothing else:
# The identity podinfo runs as. It never calls the Kubernetes API, so it gets no token.
apiVersion: v1
kind: ServiceAccount
metadata:
name: podinfo
automountServiceAccountToken: false
---
# An identity for a deployment pipeline
apiVersion: v1
kind: ServiceAccount
metadata:
name: deployer
---
# What the pipeline may do in this namespace: update Deployments and read pods
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer
rules:
- apiGroups: ['apps']
resources: ['deployments']
verbs: ['get', 'list', 'watch', 'patch', 'update']
- apiGroups: ['']
resources: ['pods']
verbs: ['get', 'list', 'watch']
---
# Who gets those permissions: the deployer ServiceAccount
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: deployer
subjects:
- kind: ServiceAccount
name: deployer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: deployer
kubectl auth can-i --as is how you test a role before you hand it out. Only the deployments and pods rows come from the Role; the others, and the rest of the list, are permissions every authenticated account has, such as reading the API discovery endpoints. A CI job gets a short-lived token for the deployer account with kubectl create token, and a token that expires in 10 minutes is worth much less to an attacker than a key that never does:
kubectl create token deployer --duration=10m | cut -c1-40
eyJhbGciOiJSUzI1NiIsImtpZCI6IkRINzVOQks3
Better still, use your CI system's OIDC tokens with the cluster's or cloud's identity integration, so there is no Kubernetes credential stored in CI at all. Rules of thumb: no cluster-admin for people or pipelines in daily work, no wildcards (*) in production roles, and no get/list on Secrets unless needed. Also remember what workload permissions imply: anyone who can create pods, or create or patch Deployments, in a namespace can run code as any ServiceAccount there and mount any Secret there. The deployer above answers no to get secrets, but it could still patch a Deployment's pod template to mount one, so trust it, and protect its token, as if it could read the namespace's Secrets. The RBAC simulator lets you practice finding missing permissions.
Pod Security Admission
Pod Security Admission is built into the API server since Kubernetes 1.25, when it replaced PodSecurityPolicy. It checks pods against three Pod Security Standards:
| Level | What it allows |
|---|---|
privileged |
Everything. For system components that need host access, such as network plugins. |
baseline |
Blocks known privilege escalations: privileged containers, host namespaces, hostPath volumes, host ports. |
restricted |
Also requires non-root, allowPrivilegeEscalation: false, all capabilities dropped and a seccomp profile. |
You choose a level per namespace with labels, for three modes: enforce rejects violating pods, warn prints a warning to the user, and audit writes it to the audit log. Before enforcing, ask the API server what would break with a server-side dry run:
kubectl label --dry-run=server --overwrite namespace demo pod-security.kubernetes.io/enforce=restricted
Warning: existing pods in namespace "demo" violate the new PodSecurity enforce level "restricted:latest"
Warning: cache-0 (and 3 other pods): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/demo labeled (server dry run)
None of the guide's pods meet restricted yet. Turn on warn and audit first, which changes nothing for running pods but shows every violation to whoever creates a pod:
Run as Non-Root, Read-Only, Without Capabilities
To meet restricted, set a security context on each pod and container. One detail trips up almost everyone. This pod sets runAsNonRoot: true and everything else the standard asks for, but not a user ID:
# Meets the restricted Pod Security Standard, but kubelet cannot check the user
apiVersion: v1
kind: Pod
metadata:
name: nonroot-test
spec:
securityContext:
runAsNonRoot: true # no runAsUser, and the image names its user instead of a number
seccompProfile:
type: RuntimeDefault
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:6.15.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ['ALL']
The podinfo image declares its user by name (USER app). The kubelet cannot tell whether a name maps to UID 0, so it refuses to start the container. The fix is to set the numeric IDs, which you can read from a running container:
kubectl exec deploy/podinfo -- id
uid=100(app) gid=101(app) groups=101(app)
The hardened Deployment runs as UID 100 and GID 101, drops all capabilities, forbids privilege escalation, uses the runtime's default seccomp profile, makes the root filesystem read-only with a small writable /tmp, runs as the podinfo ServiceAccount without a mounted token, and makes the TLS files readable only by their owner and group:
apiVersion: apps/v1
kind: Deployment
metadata:
name: podinfo
labels:
app: podinfo
spec:
replicas: 3
revisionHistoryLimit: 5
progressDeadlineSeconds: 120
minReadySeconds: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: podinfo
template:
metadata:
labels:
app: podinfo
spec:
serviceAccountName: podinfo
automountServiceAccountToken: false # podinfo never calls the Kubernetes API
securityContext:
runAsNonRoot: true
runAsUser: 100 # the image's "app" user
runAsGroup: 101
fsGroup: 101 # volume files belong to this group
seccompProfile:
type: RuntimeDefault
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:6.15.0
command: ['./podinfo']
args:
- --config-path=/podinfo/config # the ConfigMap file
- --secure-port=8443 # HTTPS, with the key pair from the Secret
- --cert-path=/podinfo/tls
- --cache-server=tcp://cache:6379
env:
# Non-secret settings, one key from the ConfigMap each
- name: PODINFO_UI_MESSAGE
valueFrom:
configMapKeyRef:
name: podinfo-config
key: PODINFO_UI_MESSAGE
- name: PODINFO_LEVEL
valueFrom:
configMapKeyRef:
name: podinfo-config
key: PODINFO_LEVEL
ports:
- name: http
containerPort: 9898
- name: https
containerPort: 8443
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ['ALL']
volumeMounts:
- name: config
mountPath: /podinfo/config
readOnly: true
- name: tmp # a writable /tmp on top of the read-only root filesystem
mountPath: /tmp
- name: tls # the Secret, as files: tls.crt and tls.key
mountPath: /podinfo/tls
readOnly: true
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
resources:
requests:
cpu: 100m # used for scheduling and as the HPA's 100 percent
memory: 64Mi
limits:
memory: 128Mi # no CPU limit: the container may use idle CPU on the node
# Prefer to put the replicas on different nodes
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: podinfo
volumes:
- name: config
configMap:
name: podinfo-config
items: # only this key becomes a file
- key: features.yaml
path: features.yaml
- name: tls
secret:
secretName: podinfo-tls
defaultMode: 0440 # owner and group may read; other users may not
- name: tmp
emptyDir:
sizeLimit: 64Mi
The cache gets the same treatment, as the image's valkey user (UID 999), with fsGroup so it can still write its volume:
# A headless Service gives each StatefulSet pod a stable DNS name: cache-0.cache-headless
apiVersion: v1
kind: Service
metadata:
name: cache-headless
labels:
app: cache
spec:
clusterIP: None
selector:
app: cache
ports:
- name: valkey
port: 6379
targetPort: valkey
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cache
labels:
app: cache
spec:
serviceName: cache-headless
replicas: 1
selector:
matchLabels:
app: cache
# Keep the volumes when the StatefulSet is deleted or scaled down (the default)
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
template:
metadata:
labels:
app: cache
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 999 # the image's "valkey" user
runAsGroup: 1000
fsGroup: 1000 # volumes are made writable for this group
seccompProfile:
type: RuntimeDefault
containers:
- name: valkey
image: valkey/valkey:9.1.2-alpine
# --appendonly yes writes every change to a log file in /data
args: ['/etc/valkey/valkey.conf', '--appendonly', 'yes']
ports:
- name: valkey
containerPort: 6379
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ['ALL']
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /etc/valkey
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
volumes:
- name: config
configMap:
name: cache-config
# One PersistentVolumeClaim per pod: data-cache-0, data-cache-1, ...
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ['ReadWriteOnce']
resources:
requests:
storage: 1Gi
No warnings this time. Check the result from inside:
An attacker who gets code execution in podinfo now cannot write to the image's files, cannot talk to the Kubernetes API with the pod's identity, and cannot gain root through a setuid binary. The key file is readable by the app group only. Now enforce the standard:
The label applied without warnings because every pod in demo now complies, and the quick kubectl run from earlier parts is rejected with a list of exactly what it lacks. Test pods in a restricted namespace need the same security context, like this one:
# A test client that meets the restricted Pod Security Standard
apiVersion: v1
kind: Pod
metadata:
name: client
labels:
role: client
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: client
image: ghcr.io/stefanprodan/podinfo:6.15.0 # has curl and nc
command: ['sleep', 'infinity']
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ['ALL']
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
Enforce restricted on every namespace that runs your apps, baseline where you must, and privileged only for system namespaces. Pin the version with pod-security.kubernetes.io/enforce-version if you want to upgrade Kubernetes before you adopt changes to the standard.
NetworkPolicies
By default every pod can connect to every other pod in the cluster. A NetworkPolicy selects pods and lists which connections they accept (ingress) or may make (egress). Once any policy selects a pod for a direction, everything not allowed in that direction is dropped. The network plugin enforces policies; kind's kindnet does, as do Cilium and Calico, while some basic plugins ignore them silently.
These policies deny all incoming traffic in demo, then let podinfo accept HTTP from pods labeled role=client, from the gateway-infra namespace and from the Prometheus pods of Part 9, and let the cache accept connections only from podinfo. A namespaceSelector and a podSelector in the same list entry must both match; in separate entries, either one is enough:
# 1. Deny all incoming traffic to every pod in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
spec:
podSelector: {}
policyTypes: ['Ingress']
---
# 2. podinfo accepts HTTP from pods labeled role=client in this namespace,
# from the gateway-infra namespace and from Prometheus (Part 9)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: podinfo-ingress
spec:
podSelector:
matchLabels:
app: podinfo
policyTypes: ['Ingress']
ingress:
- from:
- podSelector:
matchLabels:
role: client
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: gateway-infra
# namespace AND pod label in one entry: only the Prometheus pods in monitoring
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- protocol: TCP
port: 9898
---
# 3. The cache accepts connections only from podinfo
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: cache-ingress
spec:
podSelector:
matchLabels:
app: cache
policyTypes: ['Ingress']
ingress:
- from:
- podSelector:
matchLabels:
app: podinfo
ports:
- protocol: TCP
port: 6379
Start the client from above and test before and after:
The client still reaches podinfo, but no longer the cache; podinfo still reaches the cache. Dropped packets look like a timeout, not a refusal, which is the clue when you debug a policy. Since the cache has no password (Part 6 explains why), this policy is what keeps other pods away from it.
Then check the Gateway from Part 5. If GW_IP is no longer set in your shell, set it again first:
GW_IP=$(kubectl get gateway public -n gateway-infra -o jsonpath='{.status.addresses[0].value}')
curl -s -m 5 --resolve podinfo.k8s-guide.example:80:$GW_IP http://podinfo.k8s-guide.example/version; echo "curl exit code: $?"
curl exit code: 28
Exit code 28 is curl's timeout: the Gateway is blocked too. In-cluster Gateway implementations such as Envoy Gateway or Cilium run their proxies as pods, so the gateway-infra namespace rule would admit them. cloud-provider-kind's proxy runs as a Docker container outside the cluster, so its requests come from the kind Docker network. A policy for that network fixes it, on kind only. Check your kind network's IPv4 subnet first and change the cidr in the file if it is not 172.18.0.0/16:
# Only for kind with cloud-provider-kind: its Gateway proxy runs as a Docker container
# outside the cluster, so its requests come from the kind Docker network, not from a pod.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: podinfo-from-kind-gateway
spec:
podSelector:
matchLabels:
app: podinfo
policyTypes: ['Ingress']
ingress:
- from:
- ipBlock:
cidr: 172.18.0.0/16 # the IPv4 subnet of the "kind" Docker network
ports:
- protocol: TCP
port: 9898
Prometheus still reaches podinfo through the monitoring rule. Start the port-forward from Part 9 again, wait for a few scrapes after the policies (the ServiceMonitor scrapes every 15 seconds), and check the targets:
Policies add up: a connection is allowed if any policy allows it. Egress works the same way. A default-deny egress policy is a strong control against data exfiltration, but remember to allow DNS (UDP and TCP port 53 to the kube-system CoreDNS pods), or every name lookup fails. For rules by DNS name, layer 7 or cluster-wide defaults, Cilium and Calico add their own policy types.
Admission Policies
Pod Security covers a fixed list of settings. For your own rules, Kubernetes has ValidatingAdmissionPolicy (stable since 1.30), written in CEL and run inside the API server, so there is no webhook to operate. This one rejects images without an explicit tag or digest, and the :latest tag, in the demo namespace. It checks app, init and ephemeral containers, and it also matches the pods/ephemeralcontainers subresource that kubectl debug uses, so a debug container cannot bring in an unpinned image either:
# Reject containers without an explicit image tag or digest, and the :latest tag
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: pinned-images
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE', 'UPDATE']
# kubectl debug adds containers through the ephemeralcontainers subresource
resources: ['pods', 'pods/ephemeralcontainers']
variables:
- name: images
expression: >-
object.spec.containers.map(c, c.image) +
(has(object.spec.initContainers) ? object.spec.initContainers.map(c, c.image) : []) +
(has(object.spec.ephemeralContainers) ? object.spec.ephemeralContainers.map(c, c.image) : [])
validations:
- expression: >-
variables.images.all(image,
image.contains('@sha256:') ||
(image.split('/')[image.split('/').size() - 1].contains(':') &&
!image.endsWith(':latest')))
messageExpression: >-
'every container image needs a version tag or digest, and not :latest: ' +
variables.images.join(', ')
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: pinned-images
spec:
policyName: pinned-images
validationActions: ['Deny']
matchResources:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: demo
The test pod meets the restricted standard, so only the new policy can reject it:
# Meets the restricted Pod Security Standard, but uses the :latest tag
apiVersion: v1
kind: Pod
metadata:
name: latest-test
spec:
securityContext:
runAsNonRoot: true
runAsUser: 100
seccompProfile:
type: RuntimeDefault
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:latest
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ['ALL']
Start new policies with validationActions: [Warn, Audit] to see what they would block, then switch to Deny. MutatingAdmissionPolicy (stable since 1.36) changes objects the same way, for example to add default labels. Policies in a Kubernetes API can be deleted by anyone with the right permissions; since 1.37 the API server can also load them from files on disk (manifest-based admission control, beta), which suits rules that must always apply. Kyverno and OPA Gatekeeper offer larger policy libraries, reports and image signature checks.
Secrets at Rest
Part 6 showed that Secrets are base64, not encrypted. Here is the TLS key from Part 6 read directly out of etcd on the kind cluster:
kubectl -n kube-system exec etcd-k8s-guide-control-plane -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/demo/podinfo-tls --print-value-only | strings | grep "PRIVATE KEY"
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
The private key is in etcd in plain text, and so is it in every etcd backup. Encryption at rest fixes that: the API server encrypts Secrets before it writes them. It reads an EncryptionConfiguration file:
# Encrypt Secrets before the API server writes them to etcd.
# The first provider encrypts new writes; the others can still decrypt older data.
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key1
secret: ENCRYPTION_KEY # 32 random bytes, base64-encoded
- identity: {} # read Secrets that were stored before encryption was turned on
To try it, the companion repository has a second kind config that mounts this file into a one-node cluster's API server. Create a key, create that cluster, store a Secret and look at etcd again:
Creating the second cluster switched kubectl to it, and deleting it left no current context, so the last command switches back to the guide's cluster. etcd held k8s:enc:secretbox:v1:key1: followed by ciphertext, the password does not appear in it (grep -c counts 0 matches), and the API server still returns it to clients that may read it. Existing Secrets are encrypted only when they are written again (kubectl get secrets -A -o json | kubectl replace -f -). The key itself sits in a file on the control plane here; in production use a KMS v2 provider, so the key lives in a key management service. Managed services encrypt etcd for you, and most offer envelope encryption with your own KMS key.
Supply Chain: Know What You Run
- Pin images. A tag can be moved to a different image; a digest cannot. The kubelet records the digest it actually pulled:
kubectl get pods -l app=podinfo -o jsonpath='{.items[0].status.containerStatuses[0].imageID}{"\n"}'
ghcr.io/stefanprodan/podinfo@sha256:ec73780a8425f59ea49f5bc8cdff0d598805a224fbaa1f86c67a244f250fa9da
Deploy by digest (image: ghcr.io/stefanprodan/podinfo:6.15.0@sha256:...) in production, and let a tool such as Renovate or Dependabot propose updates.
- Verify signatures. podinfo's release workflow signs each image with Sigstore cosign, using the workflow's GitHub OIDC identity instead of a key. You can check that an image was built by that exact workflow:
cosign verify ghcr.io/stefanprodan/podinfo:6.15.0 --certificate-identity=https://github.com/stefanprodan/podinfo/.github/workflows/release.yml@refs/tags/6.15.0 --certificate-oidc-issuer=https://token.actions.githubusercontent.com > /dev/null
Verification for ghcr.io/stefanprodan/podinfo:6.15.0 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
To enforce this in the cluster, use Kyverno's image verification, Sigstore's policy-controller or your cloud's binary authorization feature.
- Scan images and manifests in CI with Trivy, Grype or Kubescape, and fail builds on fixable critical vulnerabilities. The Security Gates guide shows how to wire scanners into a pipeline without drowning in findings.
- Use small images from sources you trust, run your own registry mirror for production, and keep the
imagePullPolicydefault (IfNotPresent) together with digests.
Check the Cluster: kube-bench
kube-bench checks a node against the CIS Kubernetes Benchmark. It needs host access, so it cannot run in the restricted demo namespace; run it in default:
The Job ran on a worker node, so these are the worker checks. kind is a development tool and is not hardened, so findings are expected; the point is to run the same check against your real clusters, read each FAIL and WARN, and fix or consciously accept it. Managed services publish their own adapted benchmarks (EKS, GKE, AKS), because you cannot check their control planes yourself.
Audit Logging
The API server can log every request: who did what, to which object, and when. Managed services offer audit logs as a setting; on kubeadm clusters you pass --audit-policy-file and --audit-log-path to the API server. A policy that logs Secret access without the Secret's contents, and everything else at metadata level, is a reasonable start (an example, not run on kind here):
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages: ['RequestReceived']
rules:
- level: Metadata # never log Secret or ConfigMap contents
resources:
- group: ''
resources: ['secrets', 'configmaps']
- level: None # skip noisy read-only health checks
nonResourceURLs: ['/healthz*', '/readyz*', '/livez*']
- level: Metadata # everything else: who, what, when
Ship the audit log off the control plane and alert on things such as new ClusterRoleBindings, exec into production pods and reads of many Secrets at once.
Keep the Cluster Current
- Upgrade on schedule. Each minor version gets about 14 months of patches. Plan an upgrade at least once or twice a year, one minor version at a time, and read the "Deprecations and removals" section of each release announcement first. Managed services upgrade clusters that fall out of support, or charge extra for extended support.
- Patch the nodes. On managed services, turn on automatic node upgrades or replace node pools regularly; containers share the node's kernel.
- Keep the API server off the internet where you can: restrict it to your networks or a VPN, and use private clusters for production.
- Back up etcd (self-managed) and your workloads with Velero, keep manifests in Git, and test a restore into a fresh cluster.
Production Checklist
| Area | Check | Part |
|---|---|---|
| Workloads | Deployments or StatefulSets, never bare pods; at least two replicas with a PodDisruptionBudget | 4, 8 |
| Health | Readiness and liveness probes on every container; startup probes for slow starts | 3 |
| Resources | Requests on every container, memory limits, quotas and LimitRanges per namespace | 8 |
| Scaling | HPA on real load metrics; cluster autoscaling on the node pools | 8 |
| Traffic | One Gateway with routes for the public paths only; no ingress-nginx; TLS everywhere | 5 |
| Configuration | Config in ConfigMaps, generated names or checksums for rollouts; Secrets as files, never in Git | 6 |
| Data | Network storage with snapshots, Retain for important volumes, tested backups |
7 |
| Observability | Central logs, Prometheus metrics, alerts on user-facing symptoms | 9 |
| Access | Least-privilege RBAC, short-lived tokens or OIDC, no shared admin certificates | 10 |
| Pods | restricted Pod Security enforced; non-root, read-only root filesystem, no ServiceAccount token unless needed |
10 |
| Network | Default-deny NetworkPolicies, explicit allows, DNS allowed for egress | 10 |
| Supply chain | Images pinned by digest, scanned and signed; admission rules for registries and tags | 10 |
| Cluster | Secrets encrypted at rest, audit logs on, CIS benchmark reviewed, supported Kubernetes version | 10 |
| Delivery | Everything in Git, applied by CI or a GitOps tool such as Argo CD or Flux | 4, 6 |
The Kubernetes security checklist goes into more detail on the security rows.
Common Mistakes
cluster-adminfor CI. A leaked pipeline token then owns the cluster. Give pipelines a namespaced role and short-lived tokens.- Enforcing Pod Security without a dry run. Running pods keep running, but their replacements fail to start at the next rollout or node drain. Use
--dry-run=serverandwarnfirst. runAsNonRootwithoutrunAsUseron images with named users. The kubelet refuses to start them (CreateContainerConfigError).- A default-deny egress policy without a DNS rule. Every lookup times out, and the app looks broken for unrelated reasons.
- Policies the network plugin does not enforce. Test a deny like this part does; do not assume.
- Treating Secrets as encrypted. Without encryption at rest, they sit in plain text in etcd and in every backup.
Summary
- RBAC with least privilege and short-lived tokens controls who can do what.
- Pod Security Admission enforces the restricted standard per namespace; run as a numeric non-root user with a read-only root filesystem and no capabilities.
- NetworkPolicies turn the flat pod network into explicit allows; check what your Gateway's traffic looks like to them.
- ValidatingAdmissionPolicies add your own rules in CEL without webhooks.
- Encrypt Secrets at rest, deploy images by digest, verify signatures, and check clusters with kube-bench.
Where to Go Next
You have run an app on Kubernetes from a single pod to a hardened, monitored, autoscaled service. Good next steps:
- Put the manifests under Git and let Argo CD or Flux apply them, and package them with Helm or Kustomize. The GitOps deployment exercise and Helm packaging exercise are hands-on starting points.
- Create a managed cluster (Part 2) and run this guide's manifests on it, with a cloud load balancer and cloud disks.
- Practice with the Kubernetes RBAC simulator, the Pod Security Standards flashcards and the Kubernetes quiz.
- Read when Kubernetes is the wrong default before you move your next small app onto it.
Back to the guide overview.
Tags
Found an issue?