kubectl debug: The Complete Guide to Ephemeral Containers, Pod Copies and Node Debugging

kubectl debug: The Complete Guide to Ephemeral Containers, Pod Copies and Node Debugging

kubectl exec stops working exactly when you need it most: the image has no shell, the container is stuck in CrashLoopBackOff, or the problem lives on the node rather than inside the pod. kubectl debug is the command built for those cases. It can attach a throwaway debug container to a pod that is already running, create a modified copy of a pod you can poke at safely, or drop you into a shell with the node’s filesystem mounted.

This guide covers all three modes, the --profile flag (whose default changed in kubectl 1.36), custom profiles, the RBAC and Pod Security rules that usually get in the way, and the limitations nobody mentions until you hit them, such as the fact that you cannot remove an ephemeral container once it is added. If your specific problem is a distroless image, the deep dive on debugging distroless containers goes further on that case; this article is the general reference for the command itself.

What kubectl debug Does (and Why exec Is Not Enough)

kubectl exec runs a new process inside an existing container. That has three hard requirements: the container must be running, the binary you want (usually sh) must exist in its image, and whatever tools you need must also be in that image. Modern production images break all three. Distroless and scratch images ship no shell, crashing containers are never running long enough to exec into, and nobody wants tcpdump or strace baked into an application image.

kubectl debug removes those requirements by bringing its own image. Depending on the target, it does one of three things:

ModeCommand shapeWhat it createsTouches the original pod?
Ephemeral containerkubectl debug mypod -it --image=busyboxA new container inside the running podYes, adds a container to it
Pod copykubectl debug mypod -it --copy-to=mypod-debug ...A new pod cloned from the original, with changesNo (unless you add --replace)
Node debuggingkubectl debug node/mynode -it --image=ubuntuA new pod on that node using host namespacesNo pod targeted

The command is available in every supported kubectl version. Ephemeral containers, the API feature behind the first mode, have been stable since Kubernetes 1.25, so any cluster you are likely to run today supports them.

Mode 1: Ephemeral Debug Containers in a Running Pod

This is the mode people mean when they search for “kubectl debug container”. It attaches a new container to a pod that is already running, without restarting it:

kubectl debug -it mypod --image=busybox:1.36 --target=app

What happens under the hood: kubectl sends a PATCH to the pod’s ephemeralcontainers subresource, the kubelet starts the new container inside the same pod sandbox, and because of -it kubectl attaches your terminal to it. The ephemeral container automatically shares the pod’s network namespace, so localhost, the pod IP and the pod’s DNS configuration are exactly what the application sees. That alone makes it the fastest way to test connectivity from a pod’s point of view.

The –target flag and the process namespace

By default, containers in a pod do not see each other’s processes. The --target=<container> flag tells the runtime to put the debug container into the process namespace of the named container. With it, ps inside the debug container shows the application’s processes, and you can inspect them:

/ # ps aux
PID   USER     TIME  COMMAND
    1 65532     0:12 /app/server --port=8080
   23 root      0:00 sh
   29 root      0:00 ps aux

Two caveats from the official documentation. First, --target must be supported by the container runtime; containerd and CRI-O support it, but if it is not supported the ephemeral container may fail to start or start with an isolated process namespace, and ps will only show its own processes. Second, --target is only necessary when the pod does not already set shareProcessNamespace: true. If it does, every container, including the ephemeral one, already sees all processes.

Reading the application’s filesystem through /proc

Once you share the target’s process namespace, the application’s root filesystem is reachable through the proc filesystem, even though the debug container has its own image:

/ # ls /proc/1/root/app
config.yaml  server
/ # cat /proc/1/root/app/config.yaml

This is the trick that makes distroless images debuggable: you get your tools from busybox and the files from the target. If you get Permission denied on /proc/1/root, the debug container lacks the ptrace access it needs to look into another user’s process. Use --profile=general (which adds SYS_PTRACE) or, if you really need it, --profile=sysadmin. The profiles are covered in detail below.

Naming, detaching and reattaching

If you do not pass -c, kubectl generates a name like debugger-8xzrl. Name it yourself so you can find it again:

kubectl debug mypod -c debugger --image=nicolaka/netshoot --target=app -it

If your terminal disconnects, the container keeps running while its main process is alive. You can reattach with:

kubectl attach mypod -c debugger -it

When you type exit, the shell (the container’s main process) ends and the ephemeral container moves to Terminated. It is never restarted, and a terminated ephemeral container cannot be started again; you add a new one with a different name instead. Running kubectl describe pod mypod shows every ephemeral container ever added under an Ephemeral Containers: section.

Mounting volumes in a debug container

A common follow-up question is how to make kubectl debug mount a volume. The answer has two parts. You cannot add a new volume, because the pod spec’s volume list is immutable after creation. You can, however, mount a volume that the pod already declares, through a custom profile (see the profiles section) that sets volumeMounts:

# debug-mounts.yaml
volumeMounts:
- name: data
  mountPath: /data
kubectl debug mypod -it --image=busybox:1.36 --target=app \
  --profile=general --custom=debug-mounts.yaml

The name must match an entry in the pod’s spec.volumes. subPath mounts are not allowed for ephemeral containers. If what you need is just to read files the application sees, /proc/1/root is usually simpler than a mount.

Mode 2: Debugging a Copy of the Pod

Ephemeral containers are useless when the application container is crash-looping: there is no stable process to target, and you cannot change the application’s command. For those cases kubectl debug can create a copy of the pod with modifications, using --copy-to.

Changing the command of a crashing container

This is the standard fix for CrashLoopBackOff investigation. Create a copy where the failing container runs a shell instead of its entrypoint:

kubectl debug mypod -it --copy-to=mypod-debug --container=app -- sh

You now have a shell in a container with the same image, environment variables, volumes, and service account as the crashing one. From there you can inspect config files, check that mounted secrets exist, and run the original entrypoint by hand to watch it fail with full output.

The --container flag matters. The documentation is explicit: to change the command of an existing container you must name it, otherwise kubectl debug creates a new container to run the command you specified. This only works if the image has a shell; for distroless images, combine it with --set-image (next section) to swap in a debug variant.

Swapping images

--set-image changes images in the copy using the same container=image syntax as kubectl set image:

# Replace one container's image with a debug build
kubectl debug mypod -it --copy-to=mypod-debug --set-image=app=myregistry/app:1.4.2-debug

# Replace every container's image
kubectl debug mypod --copy-to=mypod-debug --set-image=*=ubuntu

Swapping to a :debug tag of the same application (many distroless-based images publish one with a busybox shell) is often the cleanest way to get a shell into an otherwise shell-less image.

Adding a debug container to the copy

You can also add a brand-new container to the copy, which works even when your cluster blocks ephemeral containers:

kubectl debug mypod -it --image=ubuntu --copy-to=mypod-debug --share-processes

--share-processes enables shareProcessNamespace in the copy so the debug container can see the application’s processes. It defaults to true when you use --copy-to, so you only need to spell it out for readability.

What gets copied and what does not

The copy is a standalone pod with no owner reference: it is not part of the original Deployment or ReplicaSet, so the controller does not manage it and it will not be replaced if it dies. Several fields are intentionally stripped so the copy behaves as a lab specimen and not as a production replica:

FieldDefault in the copyFlag to keep it
LabelsRemoved, so Services do not route traffic to it--keep-labels
AnnotationsRemoved--keep-annotations
Liveness probeRemoved, so the kubelet does not kill your session--keep-liveness
Readiness probeRemoved--keep-readiness
Startup probeRemoved--keep-startup
Init containersKept--keep-init-containers=false to skip them

Two scheduling flags are worth knowing. --same-node schedules the copy on the same node as the original, which matters when you suspect a node-specific problem. --replace deletes the original pod after creating the copy; use it with care, since for a Deployment the controller will simply create a new replica.

Remember that the copy is a real pod consuming real resources. Delete it when you are done:

kubectl delete pod mypod-debug

Mode 3: Debugging a Node with kubectl debug node

When the problem is below the pod (kubelet errors, full disks, container runtime state, host networking), you can get a shell on a node without SSH:

kubectl debug node/worker-3 -it --image=ubuntu

kubectl creates a pod named like node-debugger-worker-3-pdx84 pinned to that node. According to the documentation, the container runs in the host’s IPC, network, and PID namespaces, and the node’s root filesystem is mounted at /host. That gives you access to host files such as /host/var/log and to host processes via ps.

The pod is not privileged by default, so reading some process information may fail and chroot /host may not work. If you need full access, use the sysadmin profile:

kubectl debug node/worker-3 -it --image=ubuntu --profile=sysadmin
# inside the container
chroot /host
crictl ps
journalctl -u kubelet --since "10 min ago"

This is particularly useful on immutable operating systems. On Talos Linux, for example, there is no SSH daemon at all, so node-level inspection goes through talosctl or a node debug pod; our Talos Linux guide covers that model.

Node debug pods are not cleaned up automatically. Delete them explicitly:

kubectl delete pod node-debugger-worker-3-pdx84

Debugging Profiles: –profile Explained

Every kubectl debug session applies a profile that decides the security context of the debug container. The profile matters because it determines what you can do (read other processes, capture packets, chroot to the host) and whether Pod Security admission accepts your debug container at all.

ProfileEphemeral containerPod copyNode debugging
legacyBehaves like kubectl 1.22 (no special settings)SameHost namespaces, unprivileged
generalAdds SYS_PTRACEAdds SYS_PTRACE, shares process namespace, strips probes and labelsHost namespaces, root partition mounted, unprivileged
baselineEmpty securityContextShares process namespace, empty securityContextIsolated namespaces, no host mounts
restrictedNon-root, all capabilities droppedSame, plus shared process namespacePrivate namespaces, no host mounts
netadminAdds NET_ADMIN and NET_RAWSame, plus shared process namespaceHost namespaces plus NET_ADMIN/NET_RAW
sysadminPrivilegedPrivileged debug containerPrivileged, host namespaces

The descriptions follow the design in KEP-1441, which defines the profiles. The practical mapping is simple:

  • Use general for everyday debugging; it is enough to read /proc/1/root and attach strace or a debugger to the application.
  • Use netadmin with nicolaka/netshoot when you need tcpdump, iptables inspection or raw sockets.
  • Use baseline or restricted when the namespace enforces those Pod Security Standards; other profiles will be rejected there.
  • Use sysadmin only for node work or when nothing else works, and treat it like handing out root.

The default profile changed

For years, running kubectl debug without --profile meant legacy, and recent versions printed a warning about it. Following KEP-1441, general is the default from kubectl 1.36, and the legacy profile is scheduled for removal in 1.39. Note that the behavior depends on your kubectl client version, not on the cluster version. If you have scripts that rely on the old behavior, set --profile explicitly; it is good practice anyway because the profile is part of what you are asking the cluster to allow.

Custom profiles with –custom

Since Kubernetes 1.32, custom profiles are stable. A custom profile is a partial container spec in YAML or JSON that is merged on top of the static profile:

# custom-profile.yaml
env:
- name: JAVA_TOOL_OPTIONS
  value: "-XX:+UnlockDiagnosticVMOptions"
securityContext:
  capabilities:
    add:
    - NET_ADMIN
    - SYS_TIME
kubectl debug -it mypod --image=busybox:1.36 --target=app \
  --profile=general --custom=custom-profile.yaml

The documented constraints: a custom profile can only modify the container spec, not the pod spec, and it cannot change the container’s name, image, command, lifecycle or volumeDevices. Everything else, including env, securityContext, volumeMounts and workingDir, is fair game, which is how the volume mount example earlier works.

RBAC: What Permissions kubectl debug Needs

Most “kubectl debug does not work” reports are RBAC problems. The permissions differ by mode:

ModeRequired permissions
Ephemeral containerget on pods, patch on pods/ephemeralcontainers, create on pods/attach for -it
Pod copyget and create on pods, create on pods/attach for -it
Node debuggingget on nodes (cluster-scoped), create on pods in the target namespace, create on pods/attach

A minimal namespaced Role for ephemeral debugging looks like this:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ephemeral-debugger
  namespace: payments
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list"]
- apiGroups: [""]
  resources: ["pods/ephemeralcontainers"]
  verbs: ["patch", "update"]
- apiGroups: [""]
  resources: ["pods/attach"]
  verbs: ["create"]

The error pods "mypod" is forbidden: User "dev" cannot patch resource "pods/ephemeralcontainers" means exactly the second rule is missing.

Be deliberate about who gets this. Patching pods/ephemeralcontainers lets a user run an arbitrary image inside any pod in the namespace, sharing its network and, with --target, its process space and filesystem. That is effectively the same trust level as pods/exec, and it should be granted and audited the same way. The Kubernetes security best practices article covers how to scope this kind of access, and namespace isolation is what keeps it contained.

Pod Security Admission and kubectl debug

Pod Security admission evaluates ephemeral containers too. In a namespace labeled pod-security.kubernetes.io/enforce=restricted, a debug container that runs as root or adds capabilities is rejected, and the error mentions the violated rule, for example allowPrivilegeEscalation != false or unrestricted capabilities. The fix is to pick the matching profile:

kubectl debug -it mypod --image=busybox:1.36 --target=app --profile=restricted

With restricted, the debug container runs as non-root with all capabilities dropped, so you lose SYS_PTRACE and most of /proc/1/root becomes unreadable unless the application runs as the same UID. That is a genuine limitation of debugging under the restricted standard, not a kubectl bug.

Node debugging has the same issue in a different place: the node debug pod uses host namespaces and a hostPath mount, which the baseline and restricted standards forbid. Since kubectl creates it in your current namespace, run node debugging from a namespace that allows it (typically a dedicated, privileged-labeled namespace such as node-debug) with -n.

Limitations You Should Know Before Using It in Production

You cannot remove an ephemeral container. Like regular containers, an ephemeral container cannot be changed or removed once added. There is no kubectl delete for it. When its process exits it stays in the pod’s status as Terminated until the pod itself is deleted. If a stack of terminated debug containers bothers you, the only cleanup is replacing the pod, for example with kubectl rollout restart deployment/<name>.

No resources, no ports, no probes. Ephemeral containers cannot declare resources, ports, or probes, because pod resource allocations and networking are fixed at creation. The debug container runs without its own requests, so a heavy tool (a JVM heap dump, a large tcpdump capture written to disk) competes with the application for the pod’s resources and node capacity. Keep sessions short and output small, and read resource requests and limits if you are unsure how much headroom the pod has.

No kubectl edit. Ephemeral containers are added through a dedicated API subresource, not by editing pod.spec, so you cannot add one with kubectl edit or kubectl apply.

Static pods are not supported. Ephemeral containers do not work on static pods, which includes control-plane components on kubeadm clusters. Use node debugging for those.

It only targets pods and nodes. kubectl debug accepts a pod, a node/<name>, or a file with -f. To debug a Deployment, pick one of its pods first, for example with kubectl get pods -l app=myapp.

The copy is not the original. A --copy-to pod runs on a fresh sandbox, has no live traffic, and may land on a different node. It reproduces startup and configuration problems very well and runtime or traffic-dependent problems poorly.

Choosing a Debug Image

The debug container can use any image your nodes can pull, so pick based on the job. Pin tags so your team gets the same toolset every time.

ImageSizeBest for
busybox~2 MBQuick filesystem and process checks, wget, nslookup
alpine~4 MBSame, plus apk add for anything missing
nicolaka/netshoot~200 MBNetwork debugging: tcpdump, dig, curl, iperf3, ss, mtr
ubuntu / debian~30-50 MBNode debugging and anything needing apt
Your own debug-tools imageVariesLanguage-specific tooling (JDK tools, dlv, py-spy) and air-gapped clusters

Practical Recipes

Debugging a CrashLoopBackOff pod

Start with the logs of the previous run, since they often contain the answer:

kubectl logs mypod -c app --previous

If the logs are empty or unhelpful, create a copy that does not start the app, then run it by hand:

kubectl debug mypod -it --copy-to=mypod-debug --container=app -- sh
/ # env | sort
/ # ls -la /etc/config
/ # /app/server --port=8080

For shell-less images, swap to a debug variant with --set-image, or add a busybox container with --target and read the files through /proc/1/root, the pattern covered step by step in debugging distroless containers.

Checking network connectivity from a pod

The ephemeral container shares the pod’s network namespace, so tests run from the pod’s real IP with its real DNS configuration and NetworkPolicies:

kubectl debug -it mypod --image=nicolaka/netshoot --profile=netadmin
~ # dig +search my-service
~ # curl -sv http://my-service.payments.svc:8080/healthz
~ # tcpdump -i any -nn port 5432

If the application sits behind a service mesh sidecar, remember that outbound traffic goes through the proxy; the article on Istio proxy DNS explains why DNS results can differ between the app and a plain debug container.

kubectl debug vs kubectl exec vs Pod Copy

kubectl execkubectl debug (ephemeral)kubectl debug --copy-tokubectl debug node/
Needs a shell in the imageYesNoNo (with --image or --set-image)No
Works on a crashing containerNoBarely (nothing to target)YesNot applicable
Affects the original podNoAdds a permanent entryNo (unless --replace)No
Sees live traffic and stateYesYesNoHost view
Can change command or imageNoNoYesNot applicable
RBACpods/execpods/ephemeralcontainerspods createpods create + nodes
CleanupNonePod replacementDelete the copyDelete the debug pod

The rule of thumb: use exec if the container is running and has what you need; use an ephemeral container when you need tools the image lacks while observing the live process; use a copy when the container cannot start or you need to change how it starts; use node debugging when the problem is not inside the pod. If you prefer a GUI, desktop clients like Freelens wrap exec and logs, but the three kubectl debug modes remain the most capable option.

Frequently Asked Questions

What is the difference between kubectl debug and kubectl exec?

kubectl exec runs a command inside an existing container, so it only works if that container is running and its image contains the command. kubectl debug brings its own image: it either adds a new ephemeral container to the running pod, creates a modified copy of the pod, or starts a pod on a node. Use exec for quick checks in images that have a shell, and debug for distroless images, crash-looping containers and node-level issues.

How do I remove an ephemeral container from a pod?

You cannot. Kubernetes does not allow changing or removing an ephemeral container once it has been added. When you exit the session, the container stops and stays in the pod’s status as Terminated, but it consumes no resources. The only way to clear it is to delete or replace the pod, for example with kubectl rollout restart deployment/<name> for pods managed by a Deployment.

How do I attach a debug container to a running pod?

Run kubectl debug -it <pod> --image=busybox --target=<container>. This adds an ephemeral container to the pod without restarting it, attaches your terminal, and with --target shares the process namespace of the named container so you can see its processes and read its filesystem through /proc/1/root. If you disconnect, reattach with kubectl attach <pod> -c <debug-container-name> -it.

Can kubectl debug mount a volume?

It can mount a volume the pod already defines, but it cannot add a new one. Create a custom profile file with a volumeMounts entry whose name matches a volume in the pod spec and pass it with --custom, for example kubectl debug -it mypod --image=busybox --profile=general --custom=mounts.yaml. subPath is not allowed in ephemeral containers. Often it is simpler to read the files through /proc/1/root with --target.

Which –profile should I use with kubectl debug?

Use general for most debugging; it adds SYS_PTRACE so you can inspect other processes. Use netadmin for packet capture and network tools, baseline or restricted in namespaces that enforce those Pod Security Standards, and sysadmin only when you need a privileged container, typically for node debugging. From kubectl 1.36, general is the default when no profile is given, and legacy is scheduled for removal in 1.39.

Why does kubectl debug fail with “cannot patch resource pods/ephemeralcontainers”?

Your user or service account lacks RBAC permission on the pods/ephemeralcontainers subresource. Add a Role rule granting patch (and update) on pods/ephemeralcontainers in that namespace, along with get on pods and create on pods/attach for interactive sessions. Treat this permission like pods/exec, since it lets the holder run arbitrary images inside existing pods.

Does kubectl debug work on a CrashLoopBackOff pod?

The ephemeral container mode is of little help because the target process keeps dying. Use the copy mode instead: kubectl debug mypod -it --copy-to=mypod-debug --container=app -- sh creates a copy where the failing container runs a shell instead of its entrypoint, with the same image, environment and volumes. You can then run the original command manually and see why it fails. Delete the copy when you finish.

Do ephemeral containers use resources from the pod?

Ephemeral containers cannot declare resource requests or limits, because a pod’s resource allocation is fixed at creation. They still run on the same node and consume CPU and memory while active, so a heavy tool can compete with the application. Keep debug sessions short, avoid writing large captures inside the container, and exit when done so the process stops.

Conclusion

kubectl debug is three tools behind one command. The ephemeral container mode is what you reach for when a running pod misbehaves and its image has nothing useful in it; the copy mode is for containers that cannot start or need a different command or image; and the node mode replaces SSH for anything below the pod. Set --profile explicitly, grant pods/ephemeralcontainers as carefully as pods/exec, and remember that anything you attach to a live pod stays in its status until the pod is replaced. With those habits in place, a shell-less image stops being an obstacle to debugging.

Kubernetes HPA Scale Down Slowly? How behavior, Stabilization Windows and Tolerance Really Work

Kubernetes HPA Scale Down Slowly? How behavior, Stabilization Windows and Tolerance Really Work

If your Kubernetes HPA scales down slowly, it is almost certainly doing exactly what it was designed to do. By default the HorizontalPodAutoscaler waits for a 300-second stabilization window before removing pods: it keeps the highest replica recommendation it has computed in the last five minutes, and only drops below that once every recommendation in the window agrees. Add the 15-second sync period, the metrics-server scrape interval and a 10% tolerance band, and “traffic stopped at 10:00, pods started going away at 10:06” is the normal, healthy outcome.

The fix, if you want a different outcome, is the behavior field in autoscaling/v2. This is the short version for “scale down faster”:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 60   # default is 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 30

The rest of this article explains every moving part of HPA scale-down behavior — so you can tell the difference between an HPA that is slow on purpose, one that is stuck, and one that is flapping — and gives tested recipes for each case. If your HPA runs on memory, the memory-specific details (why memory does not fall after load drops, requests vs limits) are covered in Kubernetes HPA on memory; this page is about the scale-down machinery itself, which is the same for every metric.

What Happens Between “Load Dropped” and “Pod Removed”

When people say the HPA is slow to scale down, they usually mean the total elapsed time from the moment load falls to the moment a pod is terminated. That time is the sum of several independent delays, and it helps to see them in order:

  1. Metric collection. metrics-server scrapes the kubelets every 15 seconds by default, and the kubelet’s CPU figure is itself a rate over a short window. A drop in load shows up in the resource metrics API somewhere between a few seconds and ~30 seconds later.
  2. HPA sync period. The HPA controller in kube-controller-manager evaluates every HPA every 15 seconds (--horizontal-pod-autoscaler-sync-period). Add up to another 15 seconds.
  3. Tolerance. If the ratio between the current metric and the target is within 10% of 1.0, the controller does nothing. A gradual decline can spend minutes inside that band.
  4. Stabilization window. The controller records a recommendation every sync and, for scale-down, applies the highest recommendation from the last 300 seconds. This is the big one: five minutes minimum after the last high recommendation.
  5. Scaling policies. Once the window allows it, the default scale-down policy removes up to 100% of the excess pods every 15 seconds — so this step is fast by default, unless you slowed it down.
  6. Pod termination. The Deployment controller deletes pods, which then go through preStop hooks and terminationGracePeriodSeconds. A 60-second graceful shutdown is 60 more seconds before the pod is actually gone.

So with defaults, a clean drop from 10 replicas to 3 typically completes between 5.5 and 6.5 minutes after load falls. If you are seeing 20 minutes, or never, something else is going on — keep reading.

The Default HPA Behavior (and Why Scale-Down Is Deliberately Slow)

When you omit behavior, the HPA behaves as if you had written this (the values are straight from the Kubernetes documentation):

behavior:
  scaleDown:
    stabilizationWindowSeconds: 300
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
  scaleUp:
    stabilizationWindowSeconds: 0
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
      - type: Pods
        value: 4
        periodSeconds: 15
    selectPolicy: Max

The asymmetry is intentional. Scaling up late costs you latency and errors; scaling down late costs you a few minutes of spare capacity. So scale-up reacts immediately (no window, double the fleet or add four pods every 15 seconds, whichever is larger) and scale-down waits five minutes to make sure the drop is real.

The cluster-wide default for the scale-down window comes from the --horizontal-pod-autoscaler-downscale-stabilization flag on kube-controller-manager (5 minutes). On managed platforms — EKS, GKE, AKS — you cannot change controller-manager flags, which is exactly why the per-HPA behavior field exists. Always tune in the HPA object, never rely on the cluster flag.

How stabilizationWindowSeconds Actually Works

The stabilization window is the most misunderstood field in the HPA spec, because the name suggests a delay or a cooldown. It is neither. It is a rolling maximum (for scale-down) or rolling minimum (for scale-up) over the replica recommendations computed during the window.

Concretely: every 15 seconds the controller computes a desired replica count from the metrics and stores it with a timestamp. For scale-down, before acting it looks at all recommendations from the last stabilizationWindowSeconds and uses the highest one. A single high recommendation four minutes ago is enough to hold the fleet at that size for one more minute.

Walk through an example with the default 300-second window:

TimeLoadRecommendationRecommendations in windowApplied
10:00high101010
10:01low410, 410
10:03low310, 4, 310
10:05low310 (at 10:00), 4, 310
10:05:15low34, 3, 34
10:06:15low33, 33

This is why the scale-down happens in steps that mirror the recommendations from five minutes earlier, and why a brief spike during the quiet period resets the clock. While the window is holding replicas up, kubectl describe hpa shows the condition AbleToScale True ScaleDownStabilized with the message “recent recommendations were higher than current one, applying the highest recent recommendation”. If you see that message, the HPA is not broken — it is waiting.

A few properties of the field worth knowing:

  • The value range is 0 to 3600 seconds. Anything above one hour is rejected by API validation.
  • stabilizationWindowSeconds: 0 on scale-down means “act on the current recommendation immediately”. Useful for batch workers; dangerous for anything with bursty traffic.
  • The same field exists under scaleUp, where it works as a rolling minimum. Setting a scale-up window of 60 seconds means the HPA only scales up if the last minute of recommendations all agree — a good way to ignore one-sample spikes, at the cost of reacting a minute later.
  • The window is held in the controller’s memory. When kube-controller-manager restarts or fails over, the recommendation history is lost, and the next scale-down can happen sooner than you expect.

Scaling Policies: Pods, Percent, periodSeconds and selectPolicy

Policies limit how fast the replica count can change once the stabilization window has allowed a change. Each policy says “in any periodSeconds window, change by at most value pods (or value percent of the pods)”.

type: Pods is an absolute number: value: 2 with periodSeconds: 60 means at most two pods removed per minute, regardless of fleet size. This is the predictable option and the one I default to for scale-down.

type: Percent is relative to the replica count at the start of the period: value: 25 with periodSeconds: 60 on a 40-pod fleet allows removing 10 pods in that minute, but on a 4-pod fleet only 1. Percent policies scale with the fleet, which is what you want for large deployments and a nuisance for small ones.

periodSeconds can go up to 1800 (30 minutes). The controller tracks actual scaling events within the period, so a policy of 1 pod per 300 seconds really means “no more than one removal in any rolling five-minute span”, even across multiple HPA syncs.

When you list several policies, selectPolicy decides which one wins:

  • Max (the default) picks the policy that allows the biggest change. With Pods: 4 and Percent: 100, a 2-pod fleet can grow by 4 and a 20-pod fleet by 20.
  • Min picks the policy that allows the smallest change. This is the conservative choice for scale-down: “at most 10% or 2 pods per minute, whichever is smaller”.
  • Disabled turns scaling off in that direction completely. scaleDown.selectPolicy: Disabled means the HPA will only ever add pods.

When a policy is what’s holding the fleet back, kubectl describe hpa shows ScalingLimited True ScaleDownLimit — “the desired replica count is decreasing faster than the maximum scale rate”. That is different from ScaleDownStabilized, and it tells you which knob to turn: the window or the policy.

Tolerance and Rounding: Why the HPA Sometimes Never Scales Down

A frequent complaint is not “the HPA is slow”, but “the HPA never scales down, even though usage is clearly below target”. Two pieces of arithmetic cause most of these cases.

The 10% tolerance band. The controller computes ratio = currentMetricValue / targetValue and skips any action if the ratio is within ±0.1 of 1.0. With a 70% CPU target, an average of 64% gives a ratio of 0.914 — inside the band, so nothing happens. Your fleet can sit at 64% indefinitely, 6 points below target, without a single pod removed.

Ceiling rounding at small replica counts. The desired count is ceil(currentReplicas × ratio). Rounding up is safe for scale-up, but it makes scale-down surprisingly sticky on small fleets. With 3 replicas, a 70% target and an average of 50%:

desired = ceil(3 × 50 / 70) = ceil(2.14) = 3

The ratio (0.71) is well outside the tolerance, yet the result is still 3. To go from 3 to 2 pods, the average must drop to 46.7% or below (2/3 × 70), because those 2 pods would then run at 70%. The HPA is refusing to remove a pod that would push the remaining ones over target — which is correct, but it looks like a bug when you are staring at a dashboard that says 50%. The smaller the fleet, the bigger this effect: going from 2 to 1 requires the average to fall to half the target.

Configurable tolerance per HPA. Until recently the 10% was a cluster-wide constant (--horizontal-pod-autoscaler-tolerance), which on managed clusters meant you could not change it at all. The HPAConfigurableTolerance feature adds a tolerance field per direction:

behavior:
  scaleUp:
    tolerance: 0.05    # react when 5% above target
  scaleDown:
    tolerance: 0.15    # only scale down when 15% below target

The feature went alpha in Kubernetes 1.33, beta in 1.35 (still disabled by default in beta, so managed providers generally did not expose it) and GA in 1.37, where it is always on. On a cluster older than 1.37, check the feature gate before relying on it — an API server that does not know the field silently drops it. A lower scale-up tolerance with a higher scale-down tolerance is a sensible pairing for latency-sensitive services: react early to growth, ignore small declines.

Why an HPA Stops Scaling Down: The Checklist

When the HPA is not scaling down at all — not slowly, not at all — work through these in order. They cover the cases I have actually seen in production.

minReplicas is reached. Obvious, but check it first. ScalingLimited True TooFewReplicas in the conditions confirms it.

Another metric is holding it up. With multiple metrics, the HPA computes a desired count for each and uses the largest. CPU can be at 10% while memory or a queue-length metric keeps the fleet at its current size. kubectl describe hpa lists each metric’s current value; look for the one still near its target. For memory specifically, the reasons it does not fall after load drops are covered in the HPA memory guide.

One metric is failing. This one is subtle and documented: if any metric cannot be fetched and the remaining metrics suggest a scale-down, the controller skips scaling entirely. Scale-up still works in that situation; scale-down does not. A broken Prometheus Adapter query or a missing custom metric can therefore freeze your fleet at its peak size for days. Look for FailedGetPodsMetric, FailedGetExternalMetric or FailedGetResourceMetric events.

Pods with missing metrics. When some pods have no metrics yet (new pods, or pods on a node where metrics-server is failing), the controller recomputes conservatively and assumes those pods are at 100% of target when evaluating a scale-down. A handful of pods with no metrics can keep the average above the scale-down threshold.

Rounding on a small fleet. See the previous section. On 2 or 3 replicas, the average may need to fall far below the target before a pod is removed.

Scale-down is disabled. Someone may have set scaleDown.selectPolicy: Disabled, or a very long window. kubectl get hpa <name> -o yaml and read the behavior block — including defaults, which the API server fills in.

Something else owns the replica count. A GitOps tool that sets spec.replicas on the Deployment, a KEDA ScaledObject targeting the same Deployment, or a second HPA will fight the HPA. ArgoCD will even show the Deployment as out of sync every time the HPA changes it. Remove replicas from the manifest you apply, and make sure exactly one autoscaler targets each workload.

Requests are wrong. Utilization is usage divided by request. If requests are set far below real idle usage, “idle” is already above target and the HPA will never scale down. The fix is to right-size requests, not to tune the HPA — see Kubernetes resource requests and limits.

Why an HPA Scales Down Too Fast (or Flaps)

The opposite problem shows up on bursty workloads: the HPA removes pods, traffic comes back two minutes later, the HPA adds them again, and new pods take a while to be useful. Every cycle costs cold starts, connection re-balancing and, often, a latency spike.

Common causes:

  • A short or zero scale-down window. Teams set stabilizationWindowSeconds: 0 to “fix” slowness and create flapping. If traffic has a periodicity shorter than your window — a cron that fires every 10 minutes, a batch that arrives every quarter hour — the window must be longer than that period.
  • Aggressive Percent policies. Percent: 100 allows removing all excess pods at once. A Pods policy that removes one or two per period turns a cliff into a staircase, which gives you time to notice a returning load.
  • Slow-starting pods. If a new pod takes three minutes to warm up — JVM JIT compilation, cache loading, connection pools — every scale-down you later have to reverse costs three minutes of under-capacity. The HPA has two cluster-level safeguards for CPU (--horizontal-pod-autoscaler-cpu-initialization-period, 5 minutes, and --horizontal-pod-autoscaler-initial-readiness-delay, 30 seconds) that ignore CPU samples from pods that are still starting. They only help if your readiness probe does not report Ready before the warm-up is done. Use a startupProbe that holds the pod back until it can actually serve.

HPA Behavior Recipes (autoscaling/v2)

All of these are complete manifests you can apply as-is after changing the target and metrics. They use CPU for brevity; the behavior block is the part that matters, and it works identically with memory, custom or external metrics.

Scale down faster

For stateless services with fast startup and load that drops cleanly — internal APIs, dev and staging environments, anything where idle capacity costs more than an occasional cold start:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-fast-down
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 60
      policies:
        - type: Percent
          value: 50
          periodSeconds: 30
      selectPolicy: Max

The fleet can shrink one minute after load drops, halving at most every 30 seconds. Going below 60 seconds rarely buys anything real: metric lag already eats 15-30 seconds of it.

Scale down gradually (fast up, slow down)

The safe default for customer-facing services, bursty traffic and slow-starting runtimes:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-gradual
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 50
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
        - type: Pods
          value: 4
          periodSeconds: 30
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 600
      policies:
        - type: Pods
          value: 2
          periodSeconds: 120
        - type: Percent
          value: 10
          periodSeconds: 120
      selectPolicy: Min

Scale-up doubles the fleet (or adds 4, whichever is more) every 30 seconds. Scale-down waits until ten minutes of recommendations agree, then removes at most 2 pods or 10% every two minutes, whichever is smaller. A drop from 50 to 10 pods takes about 40 minutes, which is exactly the point: if traffic comes back, you are still mostly provisioned.

Never scale down automatically

For workloads where the HPA is there to absorb peaks, and scale-down should happen only through a deploy or a human decision — stateful consumers that rebalance partitions on every change, services with very expensive warm-up, or the first weeks of a new HPA you do not yet trust:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: consumer-no-down
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: consumer
  minReplicas: 4
  maxReplicas: 24
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      selectPolicy: Disabled

The cost: the fleet sits at its high-water mark until someone lowers maxReplicas or redeploys.

KEDA: cooldownPeriod Is Not What Controls Scale-Down

If you use KEDA, you have probably tried cooldownPeriod to slow down or speed up scale-down and seen no effect. That is because cooldownPeriod (default 300 seconds) only applies to the transition from 1 replica to 0: how long KEDA waits after the last active trigger before scaling the workload to zero. Every change between N and 1 replicas is made by the HPA that KEDA creates, and is controlled by the same behavior field described above — passed through the ScaledObject:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker
spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 0
  maxReplicaCount: 40
  cooldownPeriod: 300          # only 1 -> 0
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:                # everything between maxReplicaCount and 1
        scaleDown:
          stabilizationWindowSeconds: 120
          policies:
            - type: Percent
              value: 25
              periodSeconds: 60
  triggers:
    - type: rabbitmq
      metadata:
        queueName: jobs
        mode: QueueLength
        value: "20"
      authenticationRef:
        name: rabbitmq-auth

For queue-driven workers, a short window is usually fine: queue length is a leading signal, not a lagging one like CPU. More on KEDA triggers in event-driven autoscaling with KEDA.

How to Diagnose HPA Scale-Down with kubectl

Three commands tell you almost everything.

Watch the HPA live to see the relationship between the metric and the replica count over time:

kubectl get hpa api -w

Describe it to see the conditions and the recent events — this is where the “why” lives:

kubectl describe hpa api

A healthy HPA waiting on its stabilization window looks like this:

Metrics:                                               ( current / target )
  resource cpu on pods  (as a percentage of request):  18% (90m) / 70%
Min replicas:                                          2
Max replicas:                                          30
Behavior:
  Scale Up:
    Stabilization Window: 0 seconds
    Select Policy: Max
    Policies:
      - Type: Pods     Value: 4    Period: 15 seconds
      - Type: Percent  Value: 100  Period: 15 seconds
  Scale Down:
    Stabilization Window: 300 seconds
    Select Policy: Max
    Policies:
      - Type: Percent  Value: 100  Period: 15 seconds
Deployment pods:       10 current / 10 desired
Conditions:
  Type            Status  Reason               Message
  ----            ------  ------               -------
  AbleToScale     True    ScaleDownStabilized  recent recommendations were higher than current one, applying the highest recent recommendation
  ScalingActive   True    ValidMetricFound     the HPA was able to successfully calculate a replica count from cpu resource utilization (percentage of request)
  ScalingLimited  False   DesiredWithinRange   the desired count is within the acceptable range

How to read the conditions:

ConditionReasonMeaning
AbleToScaleScaleDownStabilizedThe stabilization window is holding replicas up. Wait, or shorten the window.
AbleToScaleReadyForNewScaleNo stabilization in effect; the recommendation is being applied as-is.
ScalingLimitedScaleDownLimitA scale-down policy is capping the rate. Loosen the policy if too slow.
ScalingLimitedTooFewReplicasminReplicas reached.
ScalingLimitedDesiredWithinRangeNo limit is being applied.
ScalingActiveValidMetricFoundMetrics are OK. Anything else here means the HPA cannot calculate at all.

If ScalingActive is False, stop tuning behavior: the HPA is not receiving metrics, and the events at the bottom of the describe output (FailedGetResourceMetric, FailedComputeMetricsReplicas) say why.

Check the raw metrics the HPA sees, to rule out metrics-server lag or missing pods:

kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" | jq '.items[] | {name: .metadata.name, cpu: .containers[].usage.cpu}'
kubectl top pods -l app=api

If some pods are missing from that output, you have found your “missing metrics treated as 100%” problem.

Pod Scale-Down Is Not Node Scale-Down

One last source of confusion: even when the HPA removes pods promptly, the nodes do not disappear at the same time, so the cloud bill does not drop when you expect. Node removal is a separate decision made by Cluster Autoscaler or Karpenter, with its own delays (Cluster Autoscaler waits 10 minutes by default before removing an underutilized node) and its own blockers, such as PodDisruptionBudgets and pods that cannot be moved. If cost is the reason you want faster scale-down, tune both layers — the comparison of how each one consolidates nodes is in Cluster Autoscaler vs Karpenter.

And before tuning behavior at all, make sure the metric is the right one: a CPU target on a service that is really bound by I/O or a downstream dependency will never scale down cleanly, no matter what the window is. The broader design questions — which metric, which target, when HPA is the wrong tool — are in Kubernetes HPA best practices.

Frequently Asked Questions

Why does my Kubernetes HPA scale down so slowly?

Because the default scale-down stabilization window is 300 seconds: the HPA applies the highest replica recommendation from the last five minutes, so pods are only removed once five minutes of recommendations agree that fewer are needed. Add 15-30 seconds of metric lag, the 15-second HPA sync period and pod termination time, and a scale-down typically starts 5.5 to 6.5 minutes after load drops. Set spec.behavior.scaleDown.stabilizationWindowSeconds to a lower value (for example 60) to make it faster.

What is the default stabilizationWindowSeconds for HPA?

For scale-down it is 300 seconds, taken from the --horizontal-pod-autoscaler-downscale-stabilization flag of kube-controller-manager unless you set it in the HPA’s behavior field. For scale-up it is 0 seconds, which means the HPA scales up as soon as a higher recommendation is computed. The field accepts values from 0 to 3600 seconds.

How do I make the HPA scale down faster?

Add a behavior.scaleDown block to your autoscaling/v2 HPA with a shorter stabilizationWindowSeconds (60 is a reasonable floor) and a permissive policy such as type: Percent, value: 50, periodSeconds: 30. If it still does not scale down, the window is not the problem: check for another metric holding the fleet up, a metric that fails to fetch, minReplicas, or the ceiling rounding that makes small fleets sticky.

Why is my HPA not scaling down even though CPU is below the target?

The most common reasons are the 10% tolerance band (at a 70% target, an average of 64% triggers nothing), ceiling rounding on small fleets (3 pods at 50% against a 70% target still compute to 3), a second metric that is still near its target, or a metric that cannot be fetched — in which case the HPA skips scale-down entirely. kubectl describe hpa shows each metric’s value, the conditions and the events that tell you which one applies.

What is the difference between stabilizationWindowSeconds and periodSeconds?

stabilizationWindowSeconds decides whether to scale: it takes the highest (scale-down) or lowest (scale-up) recommendation in the window, which filters out short-lived fluctuations. periodSeconds belongs to a scaling policy and decides how fast to scale once a change is allowed: at most value pods or percent within any periodSeconds span. The window shows up as ScaleDownStabilized in the HPA conditions, the policy as ScaleDownLimit.

Can I change the HPA tolerance per HorizontalPodAutoscaler?

Yes, from Kubernetes 1.37 the tolerance field under behavior.scaleUp and behavior.scaleDown is GA and always available, so you can set, for example, 5% for scale-up and 15% for scale-down on a single HPA. It was alpha in 1.33 and beta (disabled by default) in 1.35 and 1.36, so on older clusters it depends on the feature gate. Without it, every HPA uses the cluster-wide 10% from --horizontal-pod-autoscaler-tolerance.

Does KEDA cooldownPeriod control HPA scale-down?

No. KEDA’s cooldownPeriod (default 300 seconds) only controls how long KEDA waits before scaling from 1 replica to 0. Scaling between the maximum and 1 replica is done by the HPA that KEDA creates, and you tune it with spec.advanced.horizontalPodAutoscalerConfig.behavior in the ScaledObject, using the same scaleDown window and policies as a plain HPA.

How do I stop the HPA from scaling down at all?

Set spec.behavior.scaleDown.selectPolicy: Disabled. The HPA will still scale up when metrics exceed the target, but it will never remove pods on its own; the replica count stays at its high-water mark until you lower maxReplicas, recreate the HPA or scale down during a deploy.

Conclusion

A Kubernetes HPA that scales down slowly is, nine times out of ten, the default 300-second stabilization window doing its job, plus metric and sync lag on top. Treat that as a starting point, not a bug: shorten the window for services that start fast and cost money when idle, lengthen it and add a Pods policy for services where every cold start hurts, and disable scale-down entirely where only a human should make that call.

When the HPA does not scale down at all, stop tuning behavior and read kubectl describe hpa. The conditions tell you whether you are looking at stabilization (ScaleDownStabilized), a rate limit (ScaleDownLimit), a floor (TooFewReplicas) or broken metrics (ScalingActive False) — and the tolerance and rounding arithmetic explains the rest. Get that right, and the HPA becomes predictable enough that you can reason about its next move instead of waiting for it.

CNCF Sandbox vs Incubating vs Graduated: What the Maturity Levels Actually Mean for Your Toolchain

CNCF Sandbox vs Incubating vs Graduated: What the Maturity Levels Actually Mean for Your Toolchain

Every week brings another “we built a Kubernetes-native X” announcement. Some of those projects will be running your production clusters in three years; most will be archived repositories with a “no longer maintained” banner. The CNCF maturity level — Sandbox, Incubating, Graduated — is the most widely used shortcut for telling the two apart, and it is also one of the most misread.

The labels are not marketing tiers. Each one corresponds to a documented set of criteria that the Technical Oversight Committee (TOC) checks in a due-diligence process, and each one is a floor, not a guarantee. This article goes through what the TOC actually requires at each level as of 2026, what the levels do not tell you, the 2025–2026 events that show the ladder moving in both directions, and a practical framework for turning a badge into an adoption decision.

The Short Answer

Sandbox means the TOC judged the idea worth hosting; it says almost nothing about stability, security posture or whether anyone will maintain it next year. Incubating means the project has documented governance, at least three independent production adopters interviewed by the TOC, a security self-assessment and a passing OpenSSF badge — it is the level at which the CNCF starts actively evaluating adoption. Graduated adds maintainers from at least two organisations, an org-balance mechanism in governance and a completed third-party security audit — it is the level at which the foundation is willing to say the project has demonstrated production readiness at scale.

If you need one rule: Sandbox is where you experiment, Incubating is where you can build with a contingency plan, Graduated is where you standardise.

What the TOC Actually Requires

The criteria live in the TOC repository, and since 2024 the detailed lists sit inside the incubation and graduation application templates rather than a single criteria document. These are the items that matter for an adopter, quoted from the current templates.

CriterionSandboxIncubatingGraduated
LicenceApache 2.0, compliant before acceptanceSameSame
AdoptersNone required“Used in appropriate capacity by at least 3 independent + indirect/direct adopters”; 5–7 submitted via the Adopter Interview QuestionnaireSame requirement, evaluated at production scale
MaintainersMAINTAINERS file with a Company/Organization column“A number of active maintainers which is appropriate to the size and scope of the project” plus a documented maintainer lifecycle“Project maintainers from at least 2 organizations that demonstrates survivability”
GovernanceNot required“Clear and discoverable project governance documentation”; affiliations updated within 30 daysAdds “an org-balance mechanism for governance decisions” and documented leadership selection
SecurityNot requiredDocumented reporting process, security response roles, a Security Self-Assessment, OpenSSF Best Practices passing badgeAdds a “Third Party Security Review” with moderate and low findings tracked for resolution
ProcessNot requiredPublic roadmap, contributor ladder, documented release process, General Technical Review and Governance ReviewSame, re-verified
VoteTOC review session2/3 supermajority of the TOC2/3 supermajority of the TOC

Three things in that table are worth reading twice.

First, Sandbox has almost no technical bar. The published reasons applications get closed without review are administrative: not Apache 2.0, no MAINTAINERS file with employer affiliations, being a reference architecture rather than a reusable project, or being a subproject without the parent’s consent to split. Organisation diversity is explicitly “not a requirement, but considered during review”. The TOC reviews applications in a non-public session roughly every two months, working through seven to ten at a time in first-in-first-out order. There is no security review, no adopter interview and no architecture review at this stage — TAG-led General Technical Reviews are optional and “do not result in a label or a required action for applicants”.

Second, the OpenSSF badge requirement is “passing” at both Incubating and Graduated, not silver or gold. If a project advertises a gold badge, that is the project going beyond the bar, not the CNCF demanding it.

Third, the third-party security audit only arrives at Graduation. An Incubating project has assessed itself. That is not nothing — the self-assessment template is thorough and TAG Security reviews it — but it is not an external audit of the code you are about to run with cluster-admin privileges.

What the Levels Do Not Mean

Sandbox is not an endorsement

The lifecycle document describes Sandbox projects as ones where “failure is a possibility” and that “are expected to undergo significant changes, including breaking changes to their functionality”. It adds that they “might still be used in production by a few organizations on a case to case basis” — which is the TOC’s careful way of saying that it is possible, and that the risk is entirely yours.

The practical consequence: a Sandbox badge on a README tells you the licence is clean and the maintainers are identifiable. It does not tell you the API will exist in the same shape next quarter.

Incubating is not “almost graduated”

Projects can sit at Incubating for a very long time. Thanos entered the CNCF in July 2019 and moved to Incubating on 19 August 2020; six years later it is still Incubating and still perfectly healthy. Contrast KEDA — Sandbox March 2020, Incubating August 2021, Graduated August 2023 — or Kyverno, which took from November 2020 to March 2026 to make the full journey. Time at a level is a function of governance work and audit scheduling as much as technical maturity, so “still Incubating” is not a warning sign on its own.

Graduated is not “right for you”

Graduation says the project has cleared the foundation’s bar for governance, security and adoption breadth. It does not say the project fits your scale, your team or your problem. Cilium, Argo, Kyverno and KEDA are all Graduated; you should still not run all four if you have one cluster and three engineers. The badge removes the “will this be abandoned” question from your evaluation; it does not answer any of the others.

And some things you assume are CNCF projects are not

Karpenter is a good example of the trap. It is hosted under kubernetes-sigs as a subproject of SIG Autoscaling, which means it is governed by the Kubernetes project, not by the CNCF TOC as a standalone project with its own maturity level. It has no Sandbox, Incubating or Graduated badge to check, and any blog post that assigns it one is guessing. When you evaluate it, you are evaluating a Kubernetes SIG subproject, which is a different (and generally stricter) governance regime. The Cluster Autoscaler vs Karpenter comparison covers the technical side.

The Ladder Moves in Both Directions

2026 has been an unusually busy year for graduations: Dragonfly (January), Kyverno (16 March, announced 24 March), OpenTelemetry (May), Cloud Native Buildpacks (August), Kubeflow (August) and Karmada (8 September). Karmada is the clean illustration of the full path — Sandbox September 2021, Incubating December 2023, Graduated September 2026, with the announcement explicitly noting that the project “completed a third-party security audit, established a formal steering committee to ensure transparent governance” to get there.

The archive side is less publicised and more instructive. In 2025 the TOC archived two batches of Sandbox projects: Nocalhost, SuperEdge and KubeDL in March, then Merbridge, Sealer, Teller, DevStream and OpenELB in May. All eight were Sandbox projects, several of them with a single corporate sponsor, and each went through the documented process — a project-health issue, a two-week final comment period, then a two-week TOC vote requiring a 2/3 majority.

Archiving is also not necessarily the end. OpenEBS was archived after a TOC vote in early 2024 and re-admitted to Sandbox in October 2024 with a rebuilt maintainer team, because “any project can be reactivated into CNCF by following the normal Project Lifecycle & Process”. What an archived project loses is real, though: it “may not be used any more” to advertise its former status, and it disappears from the landscape, DevStats and CLOMonitor.

For an adopter the lesson is simple. Sandbox is the level where the foundation has the least information about the project and the least reason to fight for it. If the project has one employer behind its maintainers, the foundation’s exit process is the one you should be planning for.

Signals You Can Read Yourself

The TOC’s own inputs for health and archiving decisions are public, so you can run the same checks before the TOC does. The archiving policy lists the signals it weighs: the project health dashboard, whether the project still meets current acceptance criteria, community requests, “security responsiveness”, meeting activity, mailing-list engagement and adoption statistics.

Project Health issues. Concerns are filed in cncf/toc with the [HEALTH] prefix. The template describes its purpose as ascertaining “the current activity and health of the project so the TOC may identify the appropriate support and guidance for the project to return to an optimal state of health or determination of archival”. Search the TOC issues for the project name before you adopt; a health issue is a leading indicator by months.

Level-change applications. Incubation and graduation applications are also public issues in cncf/toc. Reading one tells you exactly what the TOC asked, which adopters were interviewed and what was flagged in the security review. This is better due diligence than most vendor RFP responses.

DevStats and LFX Insights. DevStats (devstats.cncf.io) gives contributor and company counts over time; LFX Insights adds organisational affiliation. The metric to watch is not stars but the number of companies contributing in the last year and whether that number is rising.

CLOMonitor. clomonitor.io scores every CNCF project against the same checklist the TOC uses — licence, governance files, security policy, OpenSSF badge, DCO, release signing. A low score at Incubating is worth a question; a low score at Sandbox is normal.

TAG structure. In 2025 the TOC rebooted its Technical Advisory Groups into five: Developer Experience, Infrastructure, Operational Resilience, Security and Compliance, and Workloads Foundation. TAGs no longer perform formal reviews as part of the Sandbox process; that work moved to the TOC’s Project Reviews subproject. If you are reading an old TAG review of a project, check the date — the group that wrote it may no longer exist.

A Practical Evaluation Framework

The maturity level is one input. Here is the checklist that turns it into a decision, with the commands and URLs to answer each question in under an hour.

1. Where does it sit in your stack?

A CNI plugin, a policy engine or a service mesh is foundational: replacing it is a migration project. A dashboard, a CLI plugin or a diagram generator is peripheral: replacing it is an afternoon. The same badge means different things at different layers. Headlamp is a Sandbox project (accepted 17 May 2023) and a perfectly reasonable choice as a cluster UI, because a UI is peripheral — see Headlamp vs FreeLens vs Lens. A Sandbox CNI is a different conversation.

2. Who actually maintains it?

# Committers in the last year, with email domains
git shortlog -sne --since="1 year ago" | head -20

# How concentrated is it? Count distinct email domains among the top 10
git shortlog -sne --since="1 year ago" | head -10 \
  | grep -oE '@[a-z0-9.-]+' | sort | uniq -c | sort -rn

If one domain accounts for more than about 80% of commits, the project’s roadmap is that company’s roadmap, regardless of what the governance document says. This is exactly the “org-balance” question the TOC asks at Graduation; ask it earlier.

3. What does the security posture look like?

# OpenSSF Scorecard: branch protection, signed releases, dependency update tooling, fuzzing
scorecard --repo=github.com/<org>/<project>

Then check for SECURITY.md, whether reported CVEs have advisories with fix versions, and whether release artefacts are signed. For an Incubating project, read the Security Self-Assessment linked from its application issue; for a Graduated one, find the third-party audit report — it is usually published in the project repository or by the auditor.

4. What is the release and compatibility story?

Look at the last twelve months of releases: cadence, whether minor releases carry breaking changes, and whether there is a written deprecation policy. “Fewer breaking changes and more stable, versioned APIs” is literally the TOC’s description of the Incubating transition, so a Sandbox project with a stable API is ahead of its level and an Incubating project without one is behind it.

5. Who else depends on it?

A project that is a dependency of other CNCF projects — or that a managed Kubernetes service ships by default — has a survival guarantee no badge can provide. Cilium is Graduated, but the more important fact for its longevity is that several cloud providers build their networking on it.

6. What is the exit?

Write down, before adopting, what it would take to replace the project. For a policy engine: how many policies, how portable are they (Kyverno’s YAML versus Rego, for example — see enforcing standard and custom policies with Kyverno). For a UI: nothing. For a storage layer: everything. The lower the level, the more this answer matters.

Mapping Level to Adoption Strategy

Start here:
│
├── Is the component foundational (network, storage, policy, identity, mesh)?
│   ├── YES → Graduated by default.
│   │         Incubating only with a documented exit plan and ≥2 maintainer orgs.
│   │         Sandbox: no.
│   └── NO ↓
│
├── Is it core to delivery (CI/CD, GitOps, autoscaling, observability pipeline)?
│   ├── YES → Incubating or Graduated.
│   │         Sandbox only if you are willing to fork or contribute fixes.
│   └── NO ↓
│
├── Is it peripheral (UI, CLI plugin, docs/diagram tooling, developer convenience)?
│   ├── YES → Any level; evaluate on fit and maintainer count, not badge.
│   └── NO ↓
│
└── Not a CNCF project at all (Kubernetes SIG subproject, vendor OSS, no foundation)?
    → Run the same six checks; the absence of a badge is information, not a verdict.

A few corollaries that follow from the criteria rather than from opinion:

  • Sandbox projects deserve a re-evaluation date. Put it in the calendar at twelve months. If the project has not applied for Incubation, or has a [HEALTH] issue open, that is when you decide whether to contribute or leave.
  • Incubating is the sweet spot for most platform teams. The governance and security documentation exists, adopters have been interviewed, and the project is still moving fast enough to fix the thing you need. Most of what you will evaluate this year sits here.
  • Graduated projects earn platform-wide mandates. They are the ones where investment in training, internal documentation and deep expertise has a long-term return, because the foundation has structurally reduced the abandonment risk.
  • When a project moves level, re-run the checklist, not the decision. Graduation does not change your architecture. Archiving does — it starts the clock on your exit plan, and the two-week comment period on the archive issue is where you find out whether anyone intends to fork.

Frequently Asked Questions

Is a CNCF sandbox project safe for production?

Not by virtue of the badge. Sandbox acceptance checks licence, a MAINTAINERS file and fit with the cloud-native landscape; there is no adopter interview, no security assessment and no governance requirement. The CNCF’s own lifecycle document says Sandbox projects “might still be used in production by a few organizations on a case to case basis”, which means it can be done but the due diligence is entirely yours. Treat a Sandbox project as you would any small open-source project from a single vendor: evaluate the maintainers, the security posture and your exit plan, then decide.

How long does a project stay in sandbox?

There is no fixed limit. Headlamp has been Sandbox since May 2023 and is healthy; the eight projects archived in March and May 2025 had been Sandbox for years with declining activity. The TOC can open a project-health issue at any time, and archiving requires a two-week comment period followed by a two-week vote with a 2/3 majority. A Sandbox project that has not applied for Incubation after two to three years is worth a closer look at its contributor trend, not an automatic rejection.

What is the difference between incubating and graduated?

Both require at least three independent adopters, documented governance, a security reporting process and the OpenSSF Best Practices passing badge. Graduation adds three things: maintainers from at least two organisations “that demonstrates survivability”, an org-balance mechanism in governance decisions, and a completed third-party security review with findings tracked. In practice, Graduated means an external party has audited the code and no single employer can steer the project alone; Incubating means the project has documented that it intends to get there.

Can a CNCF project be archived?

Yes, at any level. The TOC weighs signals such as the project health dashboard, security responsiveness, meeting and mailing-list activity and adoption statistics; anyone in the community can request an archive review. After a two-week public comment period and a two-week TOC vote with a 2/3 majority, the project is moved to archived, removed from the landscape and may no longer advertise its former CNCF status. Archived projects can be reactivated through the normal application process, as OpenEBS was in 2024.

Does CNCF graduated mean the project is better?

It means the project has demonstrated organisational survivability, external security review and production adoption breadth. It does not mean it is technically superior to an Incubating alternative, and it does not mean it fits your scale. Use Graduated status to remove the abandonment question from your evaluation, then compare on features, operational cost and fit exactly as you would between any two tools.

Conclusion

The CNCF maturity ladder is the best free due diligence in the industry, but only if you read it as what it is: a record of which documented criteria a project has satisfied, checked by a committee that publishes its reasoning. Sandbox tells you the licence is clean. Incubating tells you adopters have been interviewed and governance exists. Graduated tells you an outside party has audited the code and no single company can walk away with it.

None of the three tells you whether the project fits your stack, and none of them replaces the six checks above. Run them, write down the exit, and re-run them when the level changes — in either direction.

Helm Chart Management: Centralized Repos vs Per-Service Charts (and the Model That Actually Wins)

Helm Chart Management: Centralized Repos vs Per-Service Charts (and the Model That Actually Wins)

Every platform team hits the same wall at roughly the same size. Somewhere between service number 20 and service number 60, the Helm charts stop being “a few YAML files” and become a system of their own: dozens of copies of the same Deployment template, three different ways of declaring a ServiceAccount, ingress annotations that drifted apart eighteen months ago, and nobody who can say with confidence which services still run the old securityContext. The question that follows is always framed the same way — should we centralize all charts in one repo, or let every team own theirs? — and it is the wrong question, because the two answers it allows are both bad at scale.

This article is about the third option, the one most mature organisations converge on whether they planned to or not: a versioned library chart that owns the opinions, thin per-service charts that own the intent, and an OCI registry as the single distribution channel. It compares that model honestly against the two pure ones, shows the code, and ends with a decision framework and a migration path for teams currently sitting on a central chart monorepo.

The Short Answer

If you have more than a handful of services and a platform team of any size, do this:

  • Put the how — Deployment shape, probes, security context, labels, pod disruption budgets, ServiceMonitor, HPA defaults — in one library chart (type: library) that is semantically versioned and published to an OCI registry.
  • Give each service a thin application chart that lives next to its code, declares the library chart as a dependency with a range like ~1.4.0, and contains almost nothing but values.yaml and a couple of one-line templates that include the library’s named templates.
  • Publish every packaged chart to the same OCI registry you already use for images, and point Argo CD or Flux at that registry, not at Git.

Centralize the opinions, distribute the ownership, unify the distribution. Everything below is the reasoning and the mechanics.

Model 1: The Centralized Chart Repository

One repository — usually platform/helm-charts — holds every chart in the organisation, and the platform team reviews every change. It is the natural first move for a team that just watched three application squads implement ingress three different ways, and it fixes that problem quickly.

What it gets right is standardisation: one CI pipeline runs helm lint --strict, helm unittest and kubeconform on every chart; a single CODEOWNERS line puts the platform team on every PR; and when a new cluster policy lands, the people who wrote the policy also write the fix. Discovery is trivial (there is only one place to look) and shared subcharts are just directories.

What it gets wrong only shows up later, and it is structural rather than fixable with more process:

  • The platform team becomes the deploy queue. Every values tweak an application team needs — a new env var, a sidecar, a memory bump — is a PR into a repo they do not own, reviewed by people who do not know the service. The review is either rubber-stamped (so why require it?) or slow (so teams route around it).
  • The chart drifts from the code. A service’s deployment definition lives in a different repository from the service, versioned on a different cadence, with a different release process. A feature branch that needs a new ConfigMap key cannot be tested end to end without a cross-repo change.
  • Blast radius is the whole org. A change to a shared helper that is used by 200 charts is a change to 200 services, and the monorepo makes that easy to do by accident — one _helpers.tpl edit, one merge, and every next deploy picks it up.

The centralised model optimises for the platform team’s control at the exact moment the organisation needs the platform team to stop being the bottleneck.

Model 2: One Chart Per Service

The mirror image: each service repository contains charts/<service>/, the team that owns the code owns the chart, and the platform team publishes guidance rather than reviewing changes.

This is what most teams want culturally, and for good reasons. The chart is versioned with the application, a feature branch can change the manifest and the code in one PR, a single pipeline builds the image and packages the chart, and a broken chart breaks one service. Ownership is unambiguous.

The failure mode is drift, and it is inevitable rather than hypothetical. Charts are created by copying the last one, so the organisation ends up with forty forks of the same Deployment template, each frozen at whatever the copied chart looked like that quarter. When the platform team needs seccompProfile: RuntimeDefault on every pod, or a new mandatory label for cost allocation, or a probe timeout change, the work is forty PRs into forty repositories owned by forty teams with forty different priorities. Quality is a function of each team’s Helm skill, which varies enormously. Security review has to happen at admission time (Kyverno, Gatekeeper) because it cannot happen at chart time.

Per-service charts optimise for team autonomy at the cost of any ability to change the platform at scale.

Side-by-Side

ConcernCentralized repoPer-service chartsLibrary + thin charts
Who owns a service’s chartPlatform teamService teamService team (values), platform team (templates)
Standard changes across 200 servicesOne PR, 200 blast radius200 PRsOne library release + automated version bumps
Chart lives with the codeNoYesYes
Quality gatesCentral CIPer-repo, variesCentral CI on the library, template CI on services
Drift between servicesLowHighLow
Platform team as bottleneckYesNoNo
Skill required from service teamsLowHighLow
Breaking change containmentPoorGoodGood (semver range)

The third column is the rest of this article.

Model 3: A Library Chart Plus Thin Per-Service Charts

Helm has had a first-class mechanism for exactly this split since Helm 3: the library chart. A chart with type: library in its Chart.yaml cannot be installed and renders no manifests of its own; it exists only to be pulled in as a dependency so other charts can include its named templates. Unlike a regular subchart, a library chart’s templates run with the parent’s .Values and .Files, which is what makes it usable as a template engine rather than as a bundled application.

The platform team publishes one such chart — call it mycompany/common — and it owns every opinion the organisation has about how a workload should look on its clusters. A service chart then becomes remarkably small.

Chart.yaml of a service:

apiVersion: v2
name: payments-api
description: Payments API
type: application
version: 3.12.0
appVersion: "2026.09.1"
dependencies:
  - name: common
    version: "~1.4.0"
    repository: "oci://registry.mycompany.io/charts"

templates/deployment.yaml of that service:

{{- include "common.deployment" . }}

templates/service.yaml, templates/hpa.yaml, templates/servicemonitor.yaml are each a single include line as well. The service’s values.yaml is where the team spends its time:

image:
  repository: registry.mycompany.io/payments/api
  tag: "2026.09.1"
replicas: 3
resources:
  requests: { cpu: 250m, memory: 512Mi }
  limits:   { memory: 512Mi }
env:
  PAYMENT_PROVIDER: stripe
ingress:
  host: payments.internal.mycompany.io

Inside the library, common.deployment is a named template that renders the Deployment with the security context, labels, probes and topology spread the platform team decided on, reading only the values the service exposes. The Helm docs’ pattern for this is a two-step merge — a _tpl template holding the defaults and a wrapper that merges the caller’s overrides on top — so a team that genuinely needs a non-standard field can set it in values without forking the template:

{{- define "common.deployment.tpl" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "common.fullname" . }}
  labels: {{- include "common.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicas | default 2 }}
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- end }}

{{- define "common.deployment.overrides" -}}
{{- /* empty by default; a service redefines this template to override fields */ -}}
{{- end }}

{{- define "common.deployment" -}}
{{- include "common.util.merge" (list . "common.deployment.overrides" "common.deployment.tpl") }}
{{- end }}

Template names are global in Helm and the parent chart’s templates are loaded after its dependencies, so a service that needs, say, a custom terminationGracePeriodSeconds redefines common.deployment.overrides in its own _helpers.tpl with just that fragment, and the merge lays it over the platform default. Services that need nothing leave it alone.

Two things fall out of this structure that neither pure model can offer.

Standard changes become a release, not a campaign. When the platform team needs every pod to carry a new cost-allocation label, they change one template, cut common 1.5.0, and every service picks it up on its next dependency bump. No forty PRs, no hunting through forks.

Breaking changes are contained by semver. If the platform team has to rename a value or drop a field, that is common 2.0.0, and no service declaring ~1.4.0 will ever pull it by accident. Teams migrate on their own schedule, and the platform team can support 1.x with patch releases while they do. The constraint syntax is doing real governance work here: ~1.4.0 allows 1.4.x patches only, ^1.4.0 allows any 1.x including new features, and a hard pin allows nothing — see Helm version constraints explained for the exact bounds of each operator and the 0.x caret trap that catches library charts still on a 0. major. The mechanics of Chart.lock and helm dependency update are covered in Helm dependencies.

Versioning the library honestly

The library chart is the one artifact in this model whose version number carries organisational weight, so be strict with it:

  • Patch (1.4.3): a template fix that renders identically for every existing consumer, or a change to a default that no service could observe.
  • Minor (1.5.0): a new optional value, a new named template, a new default that services can opt out of.
  • Major (2.0.0): any renamed or removed value, any rendered output change that a consumer could not have opted out of, any change to the merge contract.

If you are not sure whether a change is minor or major, it is major. The cost of an unnecessary major is a version bump PR; the cost of a wrong minor is a fleet-wide surprise at deploy time.

Distribution: OCI Is the Default Now

Where charts are developed and where they are published are separate decisions, and conflating them is how teams end up serving index.yaml from a Git branch or a ChartMuseum nobody wants to run.

Since Helm 3.8 the oci:// scheme has been stable, and it is the right answer for almost everyone: charts go into the same registry as images (Harbor, ECR, ACR, GHCR, Artifact Registry), with the same authentication, replication, retention and vulnerability-scanning pipeline already in place. The classic HTTP repository with its regenerated index.yaml still works, but it is a second piece of infrastructure to run, its index can go stale relative to the packages, and it has no notion of immutable digests. New projects should not start there.

Publishing is two commands:

helm package charts/common          # → common-1.5.0.tgz
helm push common-1.5.0.tgz oci://registry.mycompany.io/charts

Note that helm push takes the namespace only — the chart name and tag are taken from Chart.yaml. Consumers reference it in dependencies: exactly as in the payments-api example above, and can install or template it directly:

helm install payments oci://registry.mycompany.io/charts/payments-api --version 3.12.0
# or pinned by digest for supply-chain guarantees
helm install payments oci://registry.mycompany.io/charts/payments-api@sha256:9f2c...

Helm 4 (released November 2025) does not change the OCI story — the commands and the oci:// dependency syntax are unchanged — but two of its features are relevant to chart management at scale. Reproducible chart archives mean helm package on the same tree produces the same bytes, which makes “has this chart actually changed?” answerable in CI. And content-addressed local caching of charts makes a pipeline that resolves a library dependency for 200 services materially cheaper. Neither is a reason to migrate on its own; both are nice once you are there.

Governance Without a Bottleneck

The library model moves the platform team’s review effort to where it has leverage: the library itself, and the CI templates every service runs.

On the library chart, be paranoid. It is the only chart whose bugs ship to everyone:

  • helm lint --strict and helm unittest against a matrix of representative consumer values — the library has no values of its own, so tests must exercise it through a fixture chart.
  • Render the fixture with helm template and validate with kubeconform against every Kubernetes minor you run.
  • Ship a values.schema.json for the values the library expects consumers to provide, so a typo in a service’s values.yaml fails at helm install rather than at runtime; the pattern and its limits are in Helm values schema validation.
  • CODEOWNERS on the library repo puts the platform team on every change. That is the one place where central review is worth the latency.

On service charts, keep the gate light and automated: a shared CI template that runs lint, helm template | kubeconform, and a diff against the currently deployed release. Helm chart testing best practices covers the tooling; the point here is that the service team owns the pipeline run and the platform team owns the pipeline template.

For fleet-wide changes, the mechanism is dependency automation, not PR campaigns. Renovate and Dependabot both understand Helm Chart.yaml dependencies, including oci:// repositories. Configure them to open a PR on every service repo whenever common cuts a release within the service’s declared range, with auto-merge for patch versions if the CI gate passes. A platform change then becomes: cut common 1.5.0, watch the bots do the rounds, chase the handful of repos whose CI failed. That is the difference between a two-day change and a two-quarter one.

Admission policy still belongs in the cluster. Kyverno or Gatekeeper is the safety net for the chart a team wrote without the library, or the Deployment somebody applied by hand. The library makes compliance the default; the admission controller makes non-compliance impossible. You want both.

How It Fits GitOps

Argo CD and Flux both consume charts from OCI registries directly, which is what makes the “publish everything to the registry” rule pay off — the GitOps repository holds which version of which chart with which values, not the chart source.

With Flux, the service’s HelmRelease points at an OCIRepository (or a HelmRepository with type: oci):

apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: payments-api
  namespace: payments
spec:
  interval: 10m
  url: oci://registry.mycompany.io/charts/payments-api
  ref:
    semver: "~3.12.0"
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: payments-api
  namespace: payments
spec:
  interval: 10m
  chartRef:
    kind: OCIRepository
    name: payments-api
  values:
    replicas: 5

Argo CD’s equivalent is an Application whose source.repoURL is the registry (no oci:// prefix in Argo’s case, and the repo must be registered with enableOCI: true) and whose source.chart and targetRevision select the chart and version.

Now separate the two decisions that are usually mashed together:

  • *Where chart source lives* — monorepo or per-service repo — is a question about ownership and review, answered above: library in the platform repo, service charts next to the service.
  • *Where charts are published*** is a question about distribution, and the answer is always “the one OCI registry”, regardless of where the source came from.

Teams that skip the publishing step and point Argo CD at chart directories in Git get a system where the deployed artifact has no version, no digest and no scan, and where “what is running in production?” is answered by a commit SHA in a repo that also contains the source of forty other charts. Publish the chart. It is one CI step.

Decision Framework

How many services deploy with Helm?
│
├── < 10, one team, no platform function
│   └── Per-service charts. Copy a good template, revisit at 15+.
│
├── 10–40, a platform team exists or is forming
│   ├── Is drift already hurting (security context, labels, probes)?
│   │   ├── YES → Library chart now; migrate the worst offenders first.
│   │   └── NO  → Per-service charts + a shared CI template; start the library
│   │             when the third team copies the second team's chart.
│   └── Never a central chart repo: it is a bottleneck at this size and
│       a migration at the next one.
│
├── 40+, multiple product teams
│   └── Library chart + thin service charts + OCI + Renovate. Not optional.
│       A central chart repo here is the platform team's entire backlog.
│
└── Regulated, or platform team must approve every deployment change
    └── Library chart with a strict values.schema.json + admission policy.
        Approval happens on the library and the policy, not on 200 PRs.

Migrating away from a central chart repo

Most teams reading this already have the monorepo. Do not big-bang it.

  1. Extract the library first. Pull the shared _helpers.tpl and the most common Deployment shape into common 0.1.0 and publish it to the registry. Nothing consumes it yet.
  2. Convert charts in the monorepo to consume the library — still in the monorepo. This is where you discover the forty subtle differences between “the same” Deployment templates. Each one is a decision: a library value, or a documented exception.
  3. Cut common 1.0.0 once the converted charts render identically to what is deployed. helm template diffs against the live release are the acceptance test.
  4. Move charts to their service repos one team at a time, in the order of who asks first. The chart is now three include lines and a values.yaml, so the move is small and the team is getting something (ownership) rather than being handed a chore.
  5. Turn on Renovate for common across the moved repos, and leave the monorepo to shrink until it only contains the library and whatever genuinely shared subcharts remain.

Expect steps 2 and 3 to take most of the time. That is not migration overhead; it is the accumulated drift finally being paid down.

Frequently Asked Questions

What is a Helm library chart?

A library chart is a chart with type: library in its Chart.yaml. It cannot be installed and renders no resources on its own; it exists to be declared as a dependency by application charts, which then include its named templates. Unlike a normal subchart, its templates execute with the parent chart’s .Values, which is what makes it suitable for centralising Deployment, Service and other resource definitions across many services.

Should Helm charts live in the same repo as the application code?

For application charts, yes: the chart is part of the service’s deployable definition and should change in the same PR as the code that needs it. The reusable templates those charts depend on belong in a separate, platform-owned library chart repository. The packaged output of both should be published to an OCI registry, which is the only place deployment tooling should read from.

Is a centralized Helm chart repository ever the right choice?

It is a reasonable first step for a small organisation that just needs consistency quickly, and it remains sensible for the library chart and a handful of genuinely shared subcharts. As the primary home for every service’s chart it does not scale: the platform team becomes the review bottleneck for every values change, and a shared-helper edit has fleet-wide blast radius.

Should I use an OCI registry or a classic Helm repository?

OCI. It has been stable since Helm 3.8, uses the registry and credentials you already run for images, supports immutable digest references, and needs no separate index.yaml server. Classic HTTP repositories still work and are fine for consuming public charts, but new internal charts should be pushed to the OCI registry with helm push.

How do I roll out a platform-wide change to hundreds of Helm charts?

Put the change in the library chart, cut a release, and let Renovate or Dependabot open version-bump PRs on every consuming repository, with auto-merge for patch releases that pass CI. If the change is breaking, release it as a new major so no service picks it up until its team moves the constraint deliberately.

How does a library chart work with Argo CD or Flux?

Transparently. The service chart declares the library as a dependency, so the packaged chart pushed to the registry already contains it under charts/. Argo CD and Flux pull the service chart from the OCI registry and render it as usual; they never need to know the library exists.

Conclusion

The centralized-versus-per-service framing offers a choice between a platform team that cannot keep up and a fleet that cannot be changed. Neither is the destination. The model that scales puts the organisation’s opinions in one versioned library chart, gives each service a thin chart it genuinely owns, and publishes everything through the OCI registry it already runs. It is more work to set up than either pure model, and every large Helm estate ends up there anyway — the only question is whether you arrive by design or by migration.

FreeLens: What It Is, How to Install It, and Whether You Should Trust It (2026)

FreeLens: What It Is, How to Install It, and Whether You Should Trust It (2026)

FreeLens is a free, MIT-licensed desktop IDE for Kubernetes. It is the community fork of OpenLens — the open-source core of Lens Desktop — created after Mirantis stopped publishing OpenLens binaries and moved Lens behind a mandatory account and a commercial licence. It runs on macOS, Windows and Linux, reads your existing kubeconfig, and gives you the cluster overview, workloads, logs, terminal, Helm and metrics views that made Lens popular in the first place, without the sign-in screen.

If you searched for “freelens” you probably want one of four things: to know whether it is legitimate, to install it, to see what it can do, or to decide between it and the alternatives. This page covers all four in that order. The long-form comparison with OpenLens and Lens lives in FreeLens vs OpenLens vs Lens, and the full extension catalogue in FreeLens Extensions in 2026 — both are linked from the relevant sections rather than repeated here.

What Is FreeLens?

Three facts cover it. FreeLens is a fork of OpenLens, so it is the same Electron application, the same UI and the same extension API that Lens users have known since 2020, continued under the MIT licence by the freelensapp organisation on GitHub. It is maintained by a core team of four people plus a release engineering team, and it ships a new release roughly every month — the current version is v1.10.3 (7 July 2026), built on Electron 41 and Node.js 24, and bundling kubectl 1.36.2 and Helm 4.2.2 so you do not need either binary installed locally. And it is not a toy: the repository sits at about 5,600 GitHub stars, with 330 forks and close to 2,000 commits on the main branch.

The word “IDE” is a Lens-era marketing term. FreeLens does not edit your application code; it is a cluster client. What it replaces is the loop of kubectl get, kubectl describe, kubectl logs -f and kubectl exec across several contexts, plus the Helm CLI for release management, plus a Grafana tab for basic resource metrics. If that loop is most of your day, FreeLens is worth ten minutes to install.

How to Install FreeLens

FreeLens publishes installers for every platform in GitHub Releases, and packages for the usual package managers. All commands below come from the project README and were checked against v1.10.3.

macOS (12 or later, Apple Silicon and Intel)

brew install --cask freelens

The cask picks the right architecture. If you do not use Homebrew, the release page has both a PKG and a DMG for arm64 and amd64.

Windows (10 or later, x64 and ARM64)

winget install Freelensapp.Freelens

Or with Scoop:

scoop bucket add extras
scoop install freelens

There are also NSIS .exe, MSI and portable builds on the release page. Since v1.10.2 the Windows binaries are code-signed, which ended the long-running problem of antivirus products flagging the installer as a false positive. If your endpoint tooling still complains, make sure you are on 1.10.2 or newer before opening a ticket.

Linux (glibc 2.34 or later — Debian 12, Ubuntu 22.04, Fedora 35 and newer)

The APT repository is the cleanest option on Debian and Ubuntu because it gives you upgrades through apt:

curl -L https://raw.githubusercontent.com/freelensapp/freelens/refs/heads/main/freelens/build/apt/freelens.asc | sudo tee /etc/apt/keyrings/freelens.asc
curl -L https://raw.githubusercontent.com/freelensapp/freelens/refs/heads/main/freelens/build/apt/freelens.sources | sudo tee /etc/apt/sources.list.d/freelens.sources
sudo apt update
sudo apt install freelens

Flatpak, if you prefer the sandboxed route:

flatpak install flathub app.freelens.Freelens
flatpak run app.freelens.Freelens

Snap:

snap install freelens --classic

Arch users have freelens-bin in the AUR, and there are .deb, .rpm and AppImage downloads for both amd64 and arm64. The AppImage needs libfuse2 and zlib1g-dev installed first, which is the usual AppImage story rather than anything FreeLens-specific.

Requirements

FreeLens supports clusters running Kubernetes 1.22 or later. For older clusters it falls back to the bundled kubectl, and you can point it at a different kubectl binary in Preferences if you need version-matched behaviour for something ancient. It reads ~/.kube/config and any additional kubeconfig files you add, so there is no import step: if kubectl works from your terminal, the same contexts appear in FreeLens.

What FreeLens Ships With

The base application covers most of what teams used Lens for, and the last three minor releases have added the features people most often asked for. Version numbers are given so you can check what your installed build has.

  • Cluster catalogue and contexts. Every context from your kubeconfig, with per-cluster settings, icons and Prometheus configuration. Multiple clusters open in tabs.
  • Workloads, config, network, storage and RBAC views. The full resource browser, with a YAML editor, describe-style detail panes and event streams. v1.10.3 added ValidatingAdmissionPolicy resources, v1.10.0 added the scheduler name in pod details and the cloud provider ID in node details.
  • Logs. Multi-container log viewer with follow, search, timestamps and, since v1.9.0, line wrapping. v1.10.3 respects the kubectl.kubernetes.io/default-container annotation, so the right container is selected first on multi-container pods.
  • Terminal and exec. A built-in terminal preloaded with the bundled kubectl and Helm and pointed at the active context, plus one-click shell into any container.
  • Port-forwarding from the UI, persisted per cluster.
  • Helm. Chart repositories, releases, values editing, upgrade and rollback, driven by the bundled Helm 4.2.x — no local Helm install required.
  • Metrics. CPU, memory, network and filesystem charts fed by an in-cluster Prometheus. v1.9.0 added OpenShift’s Prometheus with bearer-token authentication and a GET/POST switch for the query endpoint; v1.10.0 added a time-range selector for the charts.
  • In-place pod resize (v1.9.0), using the Kubernetes in-place resource resize feature where the cluster supports it.
  • Proxy support, including SOCKS5 as of v1.10.3, for clusters reachable only through a bastion.
  • Theme sync with the OS (default since v1.9.0), plus the classic light and dark themes.

What it does not ship with: any kind of server component, multi-user access or shared views. FreeLens is a desktop client whose permissions are exactly the permissions of your kubeconfig. If you need one shared UI whose access is governed by cluster RBAC, that is the Headlamp use case, covered below.

FreeLens Extensions

Extensions are where FreeLens pulls ahead of most alternatives, because it inherited the Lens extension API and the community ported the popular OpenLens extensions across. Installation takes thirty seconds: open the Extensions page (Cmd+Shift+E on macOS, Ctrl+Shift+E elsewhere), paste the npm package name — the scoped name in full — and press Install. FreeLens pulls the package from the npm registry and unpacks it; there is no separate marketplace to browse, so the package name is what matters. Extensions also expose a freelens://app/extensions/install/... deep link you can click from a README.

The ones worth knowing about:

Extensionnpm packageWhat it adds
FluxCD@freelensapp/fluxcd-extensionKustomizations, HelmReleases, sources and their reconciliation status, with dashboards per API group
Argo CD@sebastian-prokesch/freelens-argo-extensionArgo CD Applications and Argo Workflows as first-class views
Karpenter@freelensapp/karpenter-extensionNodePools and NodeClaims, useful for watching consolidation happen
Gateway API@freelensapp/gateway-api-extensionGateways, HTTPRoutes and the rest of the Gateway API resources
Resource map@freelensapp/resource-map-extensionA force graph of resources and their relationships in a namespace
Kamaji and Sveltos@freelensapp/kamaji-extension, @freelensapp/sveltos-extensionMulti-cluster control-plane and add-on management views

Two caveats. Extensions that render CRDs show empty views on clusters where the CRDs are not installed, which surprises people who install everything at once. And the old freelens-node-pod-menu package is deprecated — its functionality moved into the core app — so if a tutorial tells you to install it, skip that step. The complete list, including credential explorers and AI-assistant extensions, is in FreeLens Extensions in 2026.

FreeLens vs OpenLens vs Lens (Short Version)

The lineage is simple. Lens Desktop is the commercial product from Mirantis; it requires a Lens ID account and a paid subscription for business use. OpenLens was the open-source core that community members built into a full application; Mirantis removed the built-in pod menus in 2022, stopped publishing binaries, and the community builds eventually stalled on old Electron versions. FreeLens forked OpenLens in late 2024 to keep that codebase alive under a permissive licence with a modern toolchain, and it is now the only one of the three that is both free and maintained.

FreeLensOpenLensLens Desktop
LicenceMITMIT (archived)Proprietary, commercial for business use
Account requiredNoNoYes (Lens ID)
PriceFreeFreeFree tier for personal use; paid for organisations
MaintenanceMonthly releases, v1.10.3 (Jul 2026)No releases since community builds stoppedActive, vendor-driven
RuntimeElectron 41, kubectl 1.36, Helm 4.2Old Electron, unpatched dependenciesCurrent, but closed

The honest recommendation: if you are still on OpenLens, move to FreeLens today, because you are running an unpatched Electron browser with cluster credentials in it. If you are paying for Lens and happy, nothing forces you off it. The full argument, including the licensing timeline and the features Lens keeps exclusive, is in FreeLens vs OpenLens vs Lens.

The other comparison people ask about is Headlamp, the CNCF-hosted UI that replaced the archived Kubernetes Dashboard. Headlamp can run as a desktop app like FreeLens, but its distinctive mode is in-cluster: one shared web UI whose permissions come from Kubernetes RBAC rather than from each engineer’s laptop. For a team that wants a governed, shared view, Headlamp is the stronger institutional choice; for an individual who wants the best desktop workflow and the richest extension catalogue, FreeLens still wins. Many organisations run both, and Headlamp vs FreeLens vs Lens has the decision framework.

Who Maintains FreeLens, and What Is the Risk?

This is the question that decides whether a platform team standardises on a tool, so it deserves a straight answer rather than a badge.

FreeLens is governed by a named core team — a founder who handles project management, community and the extension ecosystem, and three maintainers covering architecture and releases, UI and documentation, and AI-related features — plus a three-person release engineering team that reviews pull requests and cuts releases. That is small, but it is not one person, and the roles are published in the README. Releases have been steady: v1.9.0 in May 2026, v1.10.0 in June, three patch releases through 7 July, with nightly builds available in a separate repository for people who want to test ahead of a release.

Funding is donations plus a bounty model through BountyHub: anyone can put money on a specific issue, and a contributor who lands the pull request collects it. The README is explicit that features are decided by the community and overseen by the core team, not by whoever pays. There is no company behind FreeLens, which cuts both ways. Nobody can relicense it or put a login wall in front of it — the thing that happened to Lens — but there is also no commercial support contract to buy, and if the core team lost interest the project would slow down the way OpenLens did.

The mitigations are real, though. The codebase is MIT and the build is reproducible from the repository, so a fork is always possible; the extension API is stable and shared with the Lens lineage; and the security-relevant dependencies — Electron, Node, kubectl, Helm — are tracked within weeks of upstream, which is more than most desktop tools manage. For an individual engineer the risk is negligible. For an organisation, treat FreeLens the way you would any community-maintained developer tool: pin a version, watch the release cadence, and keep the exit (Headlamp, or plain kubectl) in mind.

Migrating From OpenLens or Lens

Because FreeLens is the same codebase, migration is mostly nothing. Your kubeconfig is read directly, so every cluster and context appears without an import. Cluster-level settings — Prometheus endpoints, icons, namespace preferences — need to be set again, because FreeLens keeps its own configuration directory rather than reading OpenLens’s, and that is deliberate: it lets you run both side by side while you switch.

Extensions are the one thing to check. Most popular OpenLens extensions have been ported and republished under new npm names, typically @freelensapp/...; the old Lens Marketplace names will not install. Look each of yours up in the catalogue linked above before uninstalling OpenLens. Lens Desktop’s account-bound features — Lens Spaces, Lens Teamwork and the cloud-synced catalogue — have no equivalent in FreeLens, so if your team relies on them you are choosing between paying for Lens and changing your workflow, not between two clients.

Frequently Asked Questions

Is FreeLens free?

Yes. FreeLens is published under the MIT licence with no account, no free tier and no commercial edition; every feature in the application is available to individuals and organisations alike. The project is funded by donations and a per-issue bounty system, not by selling licences.

Is FreeLens safe, and who maintains it?

FreeLens is maintained by a named core team of four plus a release engineering team under the freelensapp GitHub organisation, with monthly releases that track current Electron, Node.js, kubectl and Helm versions. Windows builds are code-signed since v1.10.2, releases ship SBOM artifacts, and the source is fully public, so it is as auditable as any open-source desktop tool. The permissions it has are exactly those of your kubeconfig.

How do I install FreeLens with Homebrew?

Run brew install --cask freelens on macOS 12 or later; the cask installs the correct build for Apple Silicon or Intel and keeps it updated through brew upgrade. On Windows the equivalent is winget install Freelensapp.Freelens, and on Debian or Ubuntu the project provides an APT repository.

Does FreeLens work with any Kubernetes cluster?

It works with any cluster running Kubernetes 1.22 or later that you can reach with kubectl, whether managed (EKS, GKE, AKS, OpenShift), self-hosted or local (kind, k3s, minikube). It reads your existing kubeconfig, supports HTTP and SOCKS5 proxies for clusters behind a bastion, and bundles its own kubectl and Helm so no local binaries are required.

Is FreeLens the same as OpenLens?

FreeLens is a fork of OpenLens, so the interface, the resource views and the extension API are the same, but OpenLens is no longer maintained and its last community builds run outdated Electron versions. FreeLens continues that codebase with current dependencies, restored pod and node menus, and new features such as in-place pod resize and metrics time ranges. For anyone still on OpenLens, FreeLens is the direct replacement.

Does FreeLens have extensions?

Yes. Extensions install from the Extensions page by pasting an npm package name, and the catalogue includes FluxCD, Argo CD, Karpenter, Gateway API, Kamaji, Sveltos, a resource map and several credential and AI-assistant tools. Many former OpenLens extensions have been ported under @freelensapp/ names.

Conclusion

FreeLens is the answer to a specific question: how do I keep the Lens workflow without the Lens account? It is free, it is current, it is maintained by a small but real team with a public release cadence, and it installs in one command on every platform. Its limits are the limits of any desktop client — no shared views, no server-side RBAC — and if those matter, Headlamp exists. For everyone else, and especially for anyone still opening OpenLens, install it and move on.

Kubernetes HPA on Memory: Working Examples, CPU + Memory Combined, and Troubleshooting

Kubernetes HPA on Memory: Working Examples, CPU + Memory Combined, and Troubleshooting

You can scale a Deployment on memory with a plain autoscaling/v2 HorizontalPodAutoscaler and no extra tooling: set a Resource metric named memory, give every container a memory request, and have metrics-server running. The manifest below does exactly that. The rest of this article is about what the HPA actually does with that manifest — how the number is computed, what changes when you add CPU next to it, why it scales down slower than you expect, and what every error message in kubectl describe hpa means.

Related reading: HPA scale-down behavior and stabilizationWindowSeconds explained.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: worker-memory
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75   # % of the memory REQUEST, averaged across pods

Apply it, wait one sync period (15 seconds by default), and kubectl get hpa worker-memory should show memory: <unknown>/75% replaced by a real percentage. If it stays <unknown>, jump to the troubleshooting section — the cause is almost always a missing request or a missing metrics-server.

This is the how-to page. If you want the argument for why memory is usually the wrong signal for stateless services, that is a separate article: Kubernetes HPA best practices. Here I assume you have decided memory is your signal, or you need to understand a memory HPA someone else wrote.

How the HPA Computes Memory Utilization

Three facts explain almost every “why did it do that” question about memory-based HPA.

Utilization is a percentage of the request, not the limit. The HPA controller reads each pod’s memory usage from the resource metrics API (metrics.k8s.io, served by metrics-server), divides it by the sum of the memory requests of that pod’s containers, and averages the result across all pods of the target. A pod with requests.memory: 512Mi using 400Mi is at 78% utilization even if its limit is 2Gi. If any container in the pod has no memory request, utilization for that pod is undefined and the HPA does not act on the metric at all — which is the single most common reason a memory HPA silently does nothing.

The formula is a ratio, not a threshold. Every 15 seconds the controller computes:

desiredReplicas = ceil( currentReplicas × ( currentMetricValue / desiredMetricValue ) )

With 4 replicas averaging 90% against a 75% target, that is ceil(4 × 90/75) = ceil(4.8) = 5. With 4 replicas averaging 30%, it is ceil(4 × 30/75) = ceil(1.6) = 2. The HPA does not “add one pod when over the line”; it jumps straight to the replica count that would bring the average back to target, capped by maxReplicas, minReplicas and the scaling policies described later.

There is a tolerance band around the target. If the ratio currentMetricValue / desiredMetricValue is within ±10% of 1.0 (between 0.9 and 1.1), the HPA does nothing. That prevents flapping when memory hovers around the target. The 10% is a cluster-wide default set on kube-controller-manager with --horizontal-pod-autoscaler-tolerance. Since Kubernetes 1.37 the tolerance is also configurable per HPA and per direction through spec.behavior.scaleUp.tolerance and spec.behavior.scaleDown.tolerance (the HPAConfigurableTolerance feature: alpha in 1.33, beta but disabled by default in 1.35, stable and locked on in 1.37). On memory this matters more than on CPU, because memory moves slowly: a wide scale-down tolerance keeps a worker fleet from oscillating on a slow leak-and-GC cycle.

Two more details that bite on memory specifically. Pods that are not Ready are excluded from the average, and pods with no metrics yet are also excluded — so a burst of fresh pods that are still warming up does not drag the average down. And there is a CPU-only grace period (--horizontal-pod-autoscaler-cpu-initialization-period, 5 minutes by default) that has no memory equivalent: a new pod’s memory is counted as soon as metrics-server reports it, which for a JVM means “at full heap, immediately”.

Kubernetes HPA Memory and CPU Example

Most production HPAs that use memory use it alongside CPU. The manifest is just two entries in metrics:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-cpu-memory
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

The part people get wrong is the combination logic. Multiple metrics are OR, not AND. The controller computes a desired replica count for each metric independently and then takes the maximum. If CPU says “4 replicas” and memory says “9 replicas”, you get 9. The Deployment scales up when either metric is over its target, and it only scales down when both say fewer replicas would be fine.

This is why “I added memory to my HPA and it never scales down any more” is such a common complaint. CPU drops to nothing at night and proposes 3 replicas; memory, being sticky, is still at 70% of request across 9 pods and proposes 8. The maximum wins. The Deployment sits at 8 replicas until memory actually falls — which for a runtime that holds its heap is never. If that is your situation, the fix is not in the HPA: either the memory request is wrong (see the requests and limits guide), or memory should be a safety valve at 90% rather than a scaling signal at 80%.

A useful pattern is exactly that asymmetric configuration: CPU as the real driver at 60%, memory at 90% purely so a leak or a runaway cache spreads load before pods start getting OOM-killed. In normal operation memory never proposes more replicas than CPU does; in a leak it does, and the fleet grows while you investigate.

averageUtilization vs averageValue

Utilization targets are relative to requests. AverageValue targets are absolute. Both work for memory:

metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: AverageValue
        averageValue: 1536Mi   # scale so that the average pod uses ~1.5Gi

Use AverageValue when the request is deliberately not a good baseline — for example, a batch worker whose request is set low so it schedules on any node, but whose real working set is known and stable per unit of work. Use Utilization when you want the HPA to follow whatever the request is, so that right-sizing the request automatically re-tunes the autoscaler.

There is a trap with AverageValue and the ratio formula: the target is compared against the average per pod, and the replica count is still derived from the ratio. A target of 1536Mi with pods averaging 1700Mi gives a ratio of 1.107 — just outside the 10% tolerance — and a scale-up. A target of 1536Mi with pods averaging 1600Mi gives 1.04 and nothing happens. If you want the HPA to react to smaller deviations, that is what the per-HPA tolerance field is for.

There is also a third target.type, Value, which compares the total across all pods rather than the average. It is rarely what you want for memory.

Controlling Scale-Down: behavior for a Slow Metric

Memory does not fall the moment traffic stops. Caches stay warm, runtimes hold heap, and a pod that was at 85% ten minutes ago may still be at 80%. The default scale-down behavior — a 300-second stabilization window that uses the highest desired replica count seen in the last five minutes, then removes up to 100% of pods per 15 seconds — is aggressive once the window clears. For memory-driven workloads, slow it down explicitly:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: worker-memory
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60        # at most double the fleet per minute
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 600   # look back 10 minutes, not 5
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120       # remove at most one pod every 2 minutes
      selectPolicy: Min

Read this as: scale up fast, scale down one pod at a time and only when the last ten minutes agree. The selectPolicy: Min on scale-down means that if you list several policies, the most conservative one wins. Setting selectPolicy: Disabled on scaleDown turns scale-down off entirely — occasionally the right answer for a memory HPA that exists only to absorb leaks, where you would rather scale down through a deploy than have the HPA do it.

The stabilization window is a rolling maximum of desired replica counts, not a delay. If memory dropped enough to justify 3 replicas eight minutes ago and enough to justify 2 replicas now, a 600-second window still holds the fleet at whatever the maximum proposal in the window was. That is what keeps a GC pause from triggering a scale-down.

Troubleshooting: What the Errors Mean

Everything you need is in kubectl describe hpa <name>, in the Conditions and Events sections, plus kubectl top pod to see what metrics-server is actually reporting. The messages below are the ones you will actually see.

unable to get metrics for resource memory: no metrics returned from resource metrics api — the HPA asked metrics.k8s.io for the pods’ memory and got an empty answer. Either metrics-server is not installed (kubectl get apiservice v1beta1.metrics.k8s.io should show Available: True), or it is installed but cannot scrape the kubelets (the classic symptom is kubectl top pod returning error: Metrics API not available, fixed on many local clusters by adding --kubelet-insecure-tls to the metrics-server args), or the pods are so new that no sample exists yet. The event reason is FailedGetResourceMetric and the ScalingActive condition will be False.

FailedGetResourceMetric with missing request for memory (sometimes phrased missing request for memory in container <name> of Pod <name>) — at least one container in the target pods has no resources.requests.memory. The HPA cannot compute a percentage of nothing, so it ignores the metric. Sidecars are the usual culprit: your app has a request, the injected proxy or log shipper does not. Add a request to every container, or switch that metric to type: ContainerResource with container: <your-app> so only the container you care about is measured.

the HPA was unable to compute the replica count: ... — this is the generic wrapper; the text after the colon is the real cause and is one of the two messages above, or failed to get memory utilization when the target’s pods all failed to report. Check kubectl top pod -l <selector> — if that works and the HPA still fails, the HPA’s selector and the Deployment’s selector disagree (the HPA uses the scale subresource’s selector, so a Deployment with a changed matchLabels can leave the HPA looking at the wrong pods).

<unknown> in kubectl get hpa that never resolves — same causes as above, in this order: no metrics-server, no request on some container, pods not Ready. If TARGETS shows a value for CPU but <unknown> for memory, it is the request.

ScalingLimited: True with TooManyReplicas or TooFewReplicas — not an error. The computed replica count hit maxReplicas or minReplicas. If memory sits at 95% with the HPA pinned at maxReplicas, the fleet is undersized or the request is too small; adding replicas is not going to help.

ScalingActive: False with ScalingDisabled — the target Deployment has replicas: 0, or the HPA was created against a resource that does not implement the scale subresource. The HPA does not scale from zero on resource metrics.

It scales up but never down — re-read the combination rule above. With CPU and memory both present, memory must also fall below target. Then check the stabilization window and any selectPolicy: Disabled. Then check whether kubectl top pod shows memory genuinely staying high; if it does, the HPA is behaving correctly and the workload is what needs looking at.

A quick diagnostic sequence that covers all of these:

kubectl get apiservice v1beta1.metrics.k8s.io           # Available: True?
kubectl top pod -n default -l app=worker                 # do numbers come back?
kubectl get deploy worker -o jsonpath='{.spec.template.spec.containers[*].resources.requests.memory}'
kubectl describe hpa worker-memory | sed -n '/Conditions/,/Events/p'
kubectl get hpa worker-memory -o jsonpath='{.status.currentMetrics}' | jq

The last line is the underrated one: status.currentMetrics shows the exact average utilization the controller computed, which is the number it is plugging into the formula. When the HPA’s arithmetic looks wrong, it is nearly always because that number is not what you assumed — usually because the average includes a pod with a very different request than the others.

When Memory Actually Works as a Signal

Memory-based HPA works when memory per pod is a function of the work in flight and is released when that work finishes. That describes a narrower set of workloads than most people assume, but it is not empty:

  • Queue consumers and stream processors that buffer messages in memory: more backlog means more memory per pod, and adding pods drains the backlog.
  • In-memory caches and session stores where you want to add capacity before eviction starts, not after latency degrades.
  • Image, PDF, or video processing workers whose per-request working set is large and short-lived.
  • Anything written in a runtime that returns memory to the OS promptly under low load — Rust, Go with GOMEMLIMIT set sensibly, most C++ services.

It does not work for JVM services, which allocate their heap up front and keep it, or for Go services without a memory limit, whose GC targets a percentage of live heap rather than an absolute ceiling. For those, memory utilization is a property of the configuration, not of the load, and the HPA has nothing to react to. The best practices article goes through the runtime-by-runtime detail; the short version is that if a graph of memory against requests per second is flat, memory is not your signal.

One combination to avoid outright: a VerticalPodAutoscaler in Auto mode and an HPA on the same resource. VPA raises the request, which lowers the utilization percentage, which makes the HPA scale in, which raises per-pod usage, which makes VPA raise the request again. Run VPA in Off (recommendations only) or Initial mode on any Deployment that has a memory HPA. And remember the HPA only ever adds pods — when it hits maxReplicas because there is no room on the nodes, that is the node autoscaler’s problem, covered in Cluster Autoscaler vs Karpenter.

CPU vs Memory as an HPA Signal

CPUMemory
Tracks request load for stateless servicesUsually yesUsually no
Compressible (throttled, not killed)YesNo — OOMKill
Falls quickly when load dropsSecondsMinutes to never, runtime-dependent
Warm-up grace period in the HPAYes, cpu-initialization-periodNone
Sensitive to request accuracyModeratelyExtremely
Safe default target60–70%80–90% as a safety valve; 70–75% only when memory is proven load-proportional
Typical failure modeScales late on latency-bound workScales up and never returns, or never scales at all

Frequently Asked Questions

Can Kubernetes HPA scale on memory and CPU at the same time?

Yes. List both as Resource metrics under spec.metrics in an autoscaling/v2 HorizontalPodAutoscaler. The controller computes a desired replica count for each metric separately and applies the largest one, so the Deployment scales up when either CPU or memory exceeds its target and scales down only when both are below target. A common production pattern is CPU at 60% as the driver and memory at 90% as a safety valve.

Does HPA use memory limits or requests?

Requests. averageUtilization is the pod’s memory usage divided by the sum of its containers’ memory requests, averaged across the target’s pods. Limits are never part of the calculation. If any container in a pod has no memory request, the HPA cannot compute utilization for that pod and reports missing request for memory, and it will not scale on that metric until the request is added.

Why is my memory HPA not scaling down?

Three usual reasons. First, if the HPA also has a CPU metric, the maximum of the two proposals wins, so memory has to fall below its target too. Second, the default 300-second scale-down stabilization window uses the highest desired replica count seen in that window, so a single high sample keeps the fleet up for five minutes. Third, and most often, the memory simply is not falling: JVM heaps and Go runtimes without GOMEMLIMIT hold memory after load drops, so the HPA is reporting the truth.

What does “unable to get metrics for resource memory: no metrics returned from resource metrics api” mean?

The HPA queried the metrics.k8s.io API for the target pods’ memory and got nothing back. Check that metrics-server is installed and its APIService is Available, that kubectl top pod returns numbers for the target pods, and that the pods have been running long enough for a sample to exist. On local clusters the fix is often adding --kubelet-insecure-tls to the metrics-server deployment.

Should I use averageUtilization or averageValue for memory?

Use averageUtilization when the memory request is a meaningful baseline, so that right-sizing the request automatically re-tunes the HPA. Use averageValue (an absolute quantity such as 1536Mi) when the request is deliberately set low for scheduling reasons or when you know the real working set per pod and want to target it directly. Both feed the same ratio formula and are subject to the same 10% tolerance.

Can HPA scale a Deployment to zero on memory?

Not on resource metrics. With HPAScaleToZero enabled (beta and on by default since Kubernetes 1.37) an HPA can set minReplicas: 0, but only when at least one object or external metric is configured; with only CPU or memory metrics there are no pods to measure, so the HPA cannot decide when to scale back up. Scale-to-zero on queue depth or request rate is a job for KEDA.

Conclusion

A memory HPA is three lines of YAML and one requirement — a memory request on every container — but the behavior behind those lines is a ratio against requests, a 10% tolerance band, a maximum-wins rule across metrics, and a five-minute rolling maximum on the way down. Once you hold those four facts, every surprising thing a memory HPA does becomes predictable. Set memory as a safety valve next to CPU unless you have a graph proving memory tracks load; slow the scale-down down; and when it misbehaves, read status.currentMetrics before you touch the target.

Helm Version Constraints Explained: The Tilde `~`, Caret `^` and Other SemVer Tricks

Helm Version Constraints Explained: The Tilde `~`, Caret `^` and Other SemVer Tricks

If you have ever written a chart dependency as version: ~1.2.3 and wondered exactly which versions that will and will not pull in — or been surprised when ^0.2.3 refused to upgrade to 0.3.0 — you have run into Helm’s version constraint syntax. It is one of those features everybody uses and almost nobody reads the spec for, which is precisely why it bites.

Helm lets you pin chart versions with a small algebra of range operators: the tilde ~, the caret ^, wildcards (x, *), hyphen ranges, and boolean combinators. Get them right and your helm dependency update pulls exactly the patch releases you want and nothing that breaks you. Get them wrong and you either freeze on a stale sub-chart forever or let a breaking major slip into production.

This guide covers every operator with its exact bounds, the real-world use case for each, the two traps that catch most people (the 0.x caret behavior and silently-skipped pre-releases), and where these constraints are actually evaluated — not just in Chart.yaml dependencies, but on the helm install --version flag too. Everything here reflects current Helm 3 (and is unchanged in Helm 4).

The 30-Second Cheat Sheet

Before the details, here is every operator with its exact expansion. Pin this table.

ConstraintMatches (inclusive/exclusive)Plain English
1.2.3exactly 1.2.3Exact pin
=1.2.3exactly 1.2.3Same as above
!=1.2.3anything except 1.2.3Exclude one version
>=1.2.3, <2.0.0>= 1.2.3 and < 2.0.0Explicit range (AND)
~1.2.3>= 1.2.3, < 1.3.0Patch updates only
~1.2>= 1.2.0, < 1.3.0Patch updates only
~1>= 1.0.0, < 2.0.0Any 1.x
^1.2.3>= 1.2.3, < 2.0.0Minor + patch updates
^0.2.3>= 0.2.3, < 0.3.0⚠️ 0.x pins the minor
^0.0.3>= 0.0.3, < 0.0.4⚠️ 0.0.x pins the patch
1.2.x>= 1.2.0, < 1.3.0Wildcard patch
1.x>= 1.0.0, < 2.0.0Wildcard minor
*>= 0.0.0Anything (avoid)
1.2.3 - 1.4.5>= 1.2.3, <= 1.4.5Inclusive both ends
^1.0.0 || ^2.0.0any 1.x OR any 2.xBoolean OR

The rest of this article is each of these in depth, with the use case and the gotchas.

Where Helm Actually Uses These Constraints

Before the operators, know the two places this syntax is evaluated — they behave identically because they call the same parser:

1. Chart.yaml dependencies

Each entry under dependencies has a version field that “should contain a semantic version or version range”:

apiVersion: v2
name: my-app
version: 1.0.0
dependencies:
  - name: postgresql
    version: "~15.5.0"
    repository: "https://charts.bitnami.com/bitnami"
  - name: redis
    version: "^20.0.0"
    repository: "https://charts.bitnami.com/bitnami"

2. The --version flag

The --version flag on helm install, upgrade, pull, template and friends. Straight from the CLI docs: “This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used.”

# Latest patch of 15.5
helm install db bitnami/postgresql --version "~15.5.0"

# Any 20.x, resolved at install time
helm upgrade cache bitnami/redis --version "^20.0.0"

Under the hood, Helm 3 parses all of this with the Masterminds/semver v3 Go library (the same one Helm 4 ships). Every rule below comes from that library’s behavior, so it is consistent everywhere Helm accepts a version.

The Tilde ~: Patch Updates Only

The tilde is the conservative choice: allow patch releases, freeze the minor. It is the operator you want for a dependency you trust to follow SemVer but don’t want surprising you with new features (and new bugs) on a minor bump.

The rule: with a minor version specified, ~ allows patch-level changes; with only the major specified, it allows minor-level changes.

ConstraintExpands to
~1.2.3>= 1.2.3, < 1.3.0
~1.2>= 1.2.0, < 1.3.0
~1>= 1.0.0, < 2.0.0
~1.2.x>= 1.2.0, < 1.3.0

Use case: a stateful dependency like a database chart where minor bumps can change StatefulSet fields, default storage, or init logic. version: "~15.5.0" says “give me 15.5.4, 15.5.9 — the bug fixes — but never jump me to 15.6 automatically.” This is the pattern Helm’s own dependencies best-practices page recommends as the default: version: ~1.2.3.

The Caret ^: Minor + Patch (and the 0.x Trap)

The caret is the “trust SemVer” operator: allow anything that shouldn’t break you — new minors and patches, but not a new major. For a well-behaved dependency past 1.0.0, this is the sweet spot between staying current and staying safe.

ConstraintExpands to
^1.2.3>= 1.2.3, < 2.0.0
^1.2>= 1.2.0, < 2.0.0
^1>= 1.0.0, < 2.0.0

Use case: a mature library chart (an ingress controller, a metrics exporter) that reliably reserves breaking changes for major bumps. version: "^20.0.0" keeps you on the latest 20.x without manual bumps, and stops cold at 21.0.0 so a breaking change never lands silently.

⚠️ The 0.x Caret Trap

Here is the single most misremembered rule in the whole system. Before a 1.0.0 release, the caret treats the minor number as the stability level, because in 0.x land every minor bump is allowed to break things. So:

ConstraintExpands toNot what you might expect
^0.2.3>= 0.2.3, < 0.3.0not < 1.0.0
^0.2>= 0.2.0, < 0.3.0
^0.0.3>= 0.0.3, < 0.0.4pins the patch
^0.0>= 0.0.0, < 0.1.0
^0>= 0.0.0, < 1.0.0

If you write ^0.2.3 expecting it to track every 0.x release up to 1.0.0, you will be quietly stuck on the 0.2 line — 0.3.0 will never be pulled. On a 0.x dependency, caret behaves essentially like tilde. Given how many CNCF-adjacent charts sit at 0.x for years, this is a real-world footgun, not a trivia question. If you genuinely want to float across 0.x minors, use an explicit range: >= 0.2.3, < 1.0.0.

Wildcards: x, X and *

Wildcards let you leave a position open. They work with the comparison operators too, and on a bare = they fall back to tilde-style patch matching.

ConstraintExpands to
1.2.x>= 1.2.0, < 1.3.0
1.x>= 1.0.0, < 2.0.0
>= 1.2.x>= 1.2.0
<= 2.x< 3.0.0
*>= 0.0.0 (anything)

Use case: 1.2.x reads more explicitly than ~1.2.0 to some teams and means the same thing — pick whichever your reviewers parse faster. A bare * matches everything and should be treated as a code smell in a committed Chart.yaml; it defeats the entire point of a lock and invites a breaking major on the next helm dependency update.

Hyphen Ranges: Inclusive Both Ends

A hyphen range gives you an explicit, inclusive window on both ends — handy when you know a dependency is good from version A through version B and you want to say exactly that.

ConstraintExpands to
1.2.3 - 1.4.5>= 1.2.3, <= 1.4.5
2.3.4 - 4.5>= 2.3.4, <= 4.5

⚠️ The whitespace matters. The spaces around the hyphen are mandatory. Written without them, 1.2.3-1.4.5 is not a range at all — it parses as the single version 1.2.3 with the pre-release identifier 1.4.5. That is a completely different (and almost certainly unintended) constraint, and it will fail to match silently. Always keep the spaces: 1.2.3 - 1.4.5.

Boolean Logic: AND, OR and Comparisons

You can combine constraints. Within a group, space and comma both mean AND — they are interchangeable. Groups are then joined with || for OR.

# AND — a bounded window
version: ">= 1.2.0, < 1.5.0"

# equivalent (space instead of comma)
version: ">= 1.2.0 < 1.5.0"

# OR — support two major lines at once
version: "^1.0.0 || ^2.0.0"

# real-world: pin a window but exclude one bad release
version: ">= 1.2.0, < 2.0.0, != 1.4.2"

The full set of comparison operators is what you’d expect: =, !=, >, <, >=, <=. The != is underused and genuinely handy — when a specific patch ships a regression, != 1.4.2 skips exactly that one without abandoning your range.

Use case for ||: a chart that supports two major versions of a dependency during a migration window. ^1.0.0 || ^2.0.0 accepts either line, letting downstream users move at their own pace.

The Pre-Release Gotcha Nobody Reads

This one silently breaks CI pipelines. Ranges skip pre-release versions by default. A constraint like ~1.2.3 or >= 1.2.0 will not match 1.3.0-rc.1 or 1.2.5-beta.2, even though those versions are numerically inside the range.

The reason is deliberate: you rarely want a helm dependency update to pull a release candidate into a production chart. But it surprises people who tag pre-releases and wonder why Helm ignores them.

To opt in, add a pre-release comparator to the constraint itself — the idiomatic trick is appending -0:

# Ignores 1.2.4-rc.1 (default behavior)
version: "~1.2.3"

# Matches pre-releases too, e.g. 1.2.4-rc.1
version: "~1.2.3-0"

The -0 works because pre-release identifiers sort in ASCII order and 0 is the lowest possible, so it acts as “any pre-release or higher.” One more sharp edge from the same rule: comparisons are ASCII-cased, so >= 1.2.3-BETA will match 1.2.3-alpha, because uppercase B sorts before lowercase a. If you use pre-release channels, keep the casing consistent.

Chart.yaml Range vs Chart.lock Pin: What Actually Reproduces a Build

A constraint is a range, not a pin — and understanding the difference between Chart.yaml and Chart.lock is what separates reproducible builds from “works on my machine.”

  • Chart.yaml holds the range (~15.5.0). It expresses intent: “acceptable versions.”
  • Chart.lock holds the resolved, concrete versions plus a digest. It is generated, and it is what actually reproduces a build.

The two commands that interact with them do opposite things:

# Re-resolves the RANGE in Chart.yaml against the repo,
# picks concrete versions, and rewrites Chart.lock.
helm dependency update

# Ignores the range; rebuilds charts/ from the PIN in Chart.lock.
helm dependency build

The practical rule: commit your Chart.lock. In CI, use helm dependency build so every pipeline run installs the exact versions the lock pins — the range in Chart.yaml only governs re-resolution when you deliberately run update. Treating the range as if it were the pin is how teams end up with subtly different sub-chart versions across environments. If you’re formalizing chart quality more broadly, this pairs well with a proper chart testing setup.

Since Which Helm Version?

Two things people conflate here — the syntax and the location:

  • The constraint syntax itself is not new. Helm 2 already parsed the same Masterminds/semver ranges; it just kept dependencies in a separate requirements.yaml file.
  • What changed in Helm 3 is where dependencies live. With apiVersion: v2 charts (the Helm 3 format, released November 2019), dependencies moved into Chart.yaml. Helm’s docs are explicit: the change from v1 to v2 “added a dependencies field defining chart dependencies, which were located in a separate requirements.yaml file for v1 charts.”

So if you are on any modern Helm (3.x or the new Helm 4), the full operator set in this article is available in your Chart.yaml and on --version. If you still maintain an ancient apiVersion: v1 chart, the same operators work — they just live in requirements.yaml. Set apiVersion: v2 and move the block into Chart.yaml when you migrate.

Which Operator Should You Use? A Decision Guide

Your situationUse
Production DB / stateful chart, want only bug fixes~1.2.3 (patch only)
Well-behaved library past 1.0.0, want to stay current^1.2.3 (minor + patch)
A 0.x dependency you want to float across minorsexplicit >= 0.2.3, < 1.0.0 — not ^0.2.3
Exact reproducibility, no surprisesexact pin 1.2.3 + committed Chart.lock
Skip one known-bad release inside a range>= 1.2.0, < 2.0.0, != 1.4.2
Support two majors during a migration^1.0.0 || ^2.0.0
You need release candidatesappend -0, e.g. ~1.2.3-0

The honest default for most teams: use ~ (tilde) for stateful and infrastructure-critical dependencies where you want bug fixes and nothing else, use ^ (caret) for mature libraries you trust to respect SemVer, and always commit Chart.lock so the range never decides what actually deploys — the lock does.

Frequently Asked Questions

What does the tilde (~) mean in a Helm chart version?

The tilde allows patch-level updates and freezes the minor version: ~1.2.3 expands to >= 1.2.3, < 1.3.0, and ~1.2 means the same thing. With only a major specified, it widens one level (~1 is any 1.x). It is the operator Helm’s own dependency best-practices page recommends as the default, because it lets bug fixes through and blocks new minors that may change templates or defaults.

What does the caret (^) mean in Helm, and why does ^0.2.3 not match 0.3.0?

The caret allows minor and patch updates but never a new major: ^1.2.3 expands to >= 1.2.3, < 2.0.0. Below 1.0.0 the rule changes because in 0.x land any minor bump may break things, so the caret pins the left-most non-zero position: ^0.2.3 becomes >= 0.2.3, < 0.3.0 and ^0.0.3 becomes >= 0.0.3, < 0.0.4. If you want to float across 0.x minors, write the range explicitly: >= 0.2.3, < 1.0.0.

Can I use a version range with helm install –version?

Yes. The --version flag on helm install, helm upgrade, helm pull and helm template accepts the same constraint syntax as Chart.yaml dependencies, so --version "~15.5.0" installs the newest 15.5.x available in the repository at that moment. If the flag is omitted, Helm uses the latest non-pre-release version. Both places go through the same Masterminds/semver parser, so the expansion rules are identical.

Why does my Helm dependency ignore pre-release versions?

Because ranges skip pre-releases by default: ~1.2.3 will not match 1.2.4-rc.1 even though it is numerically inside the window. This is deliberate, so that helm dependency update never pulls a release candidate into a production chart by accident. To opt in, append a pre-release comparator to the constraint, typically -0, as in ~1.2.3-0, which matches any pre-release of the allowed versions as well as the final releases.

What is the difference between Chart.yaml and Chart.lock?

Chart.yaml stores the version range, which expresses which versions are acceptable; Chart.lock stores the concrete versions and digest that were actually resolved, and it is what reproduces a build. helm dependency update re-resolves the ranges and rewrites the lock, while helm dependency build ignores the ranges and rebuilds charts/ from the lock. Commit Chart.lock and use dependency build in CI so every pipeline installs exactly the same sub-chart versions.

Wrapping Up

Helm’s version constraints are a small language, but the two traps — caret pinning the minor on 0.x, and ranges silently skipping pre-releases — cause an outsized share of “why won’t it upgrade?” and “why did that break?” incidents. Internalize the cheat-sheet table, prefer ~ and ^ over bare wildcards, and let Chart.lock be the source of truth for reproducible builds.

For more on getting Helm right in production, see the companion guides on Helm values JSON schema validation, loading external files into ConfigMaps and Secrets, and what’s new in Helm 4.

Sources

FreeLens Extensions in 2026: The Complete Catalogue and How to Install Them

FreeLens Extensions in 2026: The Complete Catalogue and How to Install Them

FreeLens Extensions: The Short Answer

FreeLens installs extensions from npm. Open the Extensions page (⌘⇧E on macOS, Ctrl+Shift+E on Linux and Windows), paste the package name — for example @freelensapp/resource-map-extension — and press Install. There is no curated in-app marketplace yet, so the package name is the address: you get it from the extension’s repository or its npm page.

Related reading: FreeLens overview and installation.

Two things trip up almost everyone arriving from OpenLens. First, @freelensapp/freelens-node-pod-menu is deprecated and now ships as an empty package — installing it does nothing, because that functionality moved into FreeLens itself. Second, OpenLens and Lens extensions are not drop-in compatible; several have been ported, but you install the FreeLens port, not the original.

This catalogue is current as of FreeLens v1.10.3 (July 2026). If you are still deciding which client to standardise on, see FreeLens vs OpenLens vs Lens.

How to Install a FreeLens Extension

  1. Open FreeLens and go to File → Extensions, or press ⌘⇧E / Ctrl+Shift+E.
  2. Paste the npm package name into the input field — the scoped name in full, including the @freelensapp/ prefix where it applies.
  3. Press Install. FreeLens downloads the package from the npm registry and unpacks it.
  4. Wait for the extension to appear in the installed list with its Enabled toggle on. Some extensions add a new entry to the left sidebar; others add menu items to existing resource views, so a missing sidebar icon does not mean the install failed.

Extensions are per-user, not per-cluster: once installed, an extension is available for every cluster in your catalogue. If an extension needs CRDs that a given cluster does not have — the Karpenter or FluxCD extensions, for instance — its views simply stay empty on that cluster rather than erroring.

Official FreeLens Extensions

These live under the freelensapp GitHub organisation and are maintained alongside the app itself.

Extensionnpm packageWhat it gives you
FluxCD@freelensapp/fluxcd-extensionKustomizations, HelmReleases, GitRepositories and sources as first-class views, with reconciliation state. The most adopted extension by a wide margin
AI@freelensapp/ai-extensionAn LLM assistant inside the IDE for explaining resources and drafting manifests
Gateway API@freelensapp/gateway-api-extensionGateways, HTTPRoutes and GatewayClasses — the successor to Ingress
Resource Map@freelensapp/resource-map-extensionLive force-directed graph of resources and their relations. Ported from the OpenLens original
Karpenter@freelensapp/karpenter-extensionNodePools, NodeClaims and provisioning decisions for the node autoscaler
Kamaji@freelensapp/kamaji-extensionHosted control planes managed by Kamaji
Sveltos@freelensapp/sveltos-extensionAdd-on distribution across fleets of clusters
Agent Bridge@freelensapp/agentbridge-extensionExposes the cluster to an external AI agent (Claude, Copilot, OpenCode)
For Claude@freelensapp/for-claude-extensionClaude-specific integration, still early (0.x)
Node & Pod Menu@freelensapp/freelens-node-pod-menuDeprecated — empty package. Do not install it

If you run Karpenter, the extension pairs naturally with the trade-offs covered in Karpenter vs Cluster Autoscaler — seeing NodeClaims appear and consolidate in real time makes the disruption behaviour much easier to reason about than reading events.

Community Extensions

Maintained by individuals outside the core organisation. They are genuinely useful, but check the version number before you rely on one in a work setting — several are still 0.x.

Extensionnpm packageWhat it gives you
Credential Explorerfreelens-credential-explorerSecret health and ownership: age, expiry, and the Secret → Deployment → Pods → ServiceAccount chain
Duplicate@omarluq/freelens-duplicate-extensionClones a running pod into a debug copy without editing manifests
Argo@sebastian-prokesch/freelens-argo-extensionArgo CD Applications and Argo Workflows resources
Workload Topologyfreelens-workload-topologyTopology view focused on workloads rather than the whole resource graph
Pod File Browserfreelens-pod-filebrowserBrowses a pod’s filesystem without dropping into kubectl exec
JobSetfreelens-jobset-extensionJobSet resources, relevant for batch and ML workloads

Spotting Expiring Tokens, Certificates and Stale Secrets

This is the single most common thing people go looking for an extension to solve, and stock FreeLens does not do it: the Secrets view shows you that a Secret exists, not whether the thing inside it has expired. A TLS certificate three days from expiry looks exactly like one with a year left.

Credential Explorer (freelens-credential-explorer) is the extension aimed squarely at that gap. It builds a dashboard around the questions you actually ask during an incident: how old is this Secret, when does it expire, which namespace is it in, and what is consuming it — following the chain from Secret through Deployment and Pods to the ServiceAccount. It recognises credentials from Vault, External Secrets, Argo CD, cert-manager and TLS certificates, Helm releases, and plain Opaque secrets.

Two caveats worth knowing before you install it: it declares itself a proof of concept at 0.1.x, and it requires FreeLens v1.9.0 or later. For a cluster where certificate expiry is a genuine production risk, treat it as a fast triage view rather than as your alerting — the durable answer is a cert-manager alert or a Prometheus rule on certmanager_certificate_expiration_timestamp_seconds, which pages you at three in the morning when a desktop IDE cannot.

Why freelens-node-pod-menu Does Nothing Any More

Early FreeLens shipped without the node and pod context menus that OpenLens users expected — shell into a pod, cordon a node, view logs from the right-click menu. The gap was filled by a separate extension, @freelensapp/freelens-node-pod-menu, and just about every migration guide written in 2025 tells you to install it.

That functionality has since been absorbed into FreeLens itself. The package still exists on npm and still installs cleanly, but it is now an empty package that provides nothing. If you followed an older guide and are wondering why the extension appears installed yet contributes no visible feature, this is why — and the menus you were after are already there in the core app. Uninstall it and move on.

Can You Install OpenLens or Lens Extensions?

Not directly. FreeLens forked from OpenLens, so the extension API is closely related and porting is usually mechanical, but the packages are published separately and an original Lens extension is not guaranteed to load. In practice a good number of the popular OpenLens extensions have already been ported — the resource map is the obvious example — and the pattern is to look for a freelens- flavoured package rather than to force the original.

If the extension you depend on has no port, forking and adapting it is a realistic afternoon of work where the licence permits, and the project publishes an example extension as a starting template. That is a meaningful difference from Lens Desktop, where the licensing of the binaries is the constraint rather than the code.

When an Extension Will Not Install

  • Nothing happens after pressing Install. Check the package name character for character — the scope matters, and @freelensapp/resource-map-extension and freelens-resource-map-extension are not the same string. The npm name is authoritative, not the GitHub repository name.
  • The extension installs but its views are empty. Almost always missing CRDs on that cluster. The FluxCD, Karpenter, Gateway API, Sveltos and Kamaji extensions all render nothing useful against a cluster that does not run the corresponding controller.
  • It installed but added no sidebar entry. Not every extension adds one; some only extend existing detail panes and context menus.
  • It worked before an upgrade and now does not. Community extensions track FreeLens releases loosely. Pin your FreeLens version if an extension is load-bearing for your workflow, and check the extension’s declared minimum version before upgrading the app.

Frequently Asked Questions

How do I install extensions in FreeLens?

Open the Extensions page with ⌘⇧E on macOS or Ctrl+Shift+E on Linux and Windows, paste the extension’s npm package name (for example @freelensapp/fluxcd-extension) and press Install. FreeLens pulls the package straight from the npm registry — there is no separate marketplace to browse, so the package name is what you need.

Do OpenLens and Lens extensions work in FreeLens?

Not as-is. FreeLens forked from OpenLens so the extension API is close and porting is usually straightforward, but packages are published separately and an original Lens extension is not guaranteed to load. Look for a FreeLens port — many popular OpenLens extensions, including the resource map, already have one.

Why does the freelens-node-pod-menu extension do nothing?

Because it is deprecated and now ships as an empty package. It existed to add back node and pod context menus that early FreeLens lacked; that functionality has since moved into the core app. Older migration guides still recommend installing it — you can safely uninstall it, the menus are already there.

Is there a FreeLens extension that shows when Secrets or certificates expire?

Yes — freelens-credential-explorer. It reports Secret age and expiry and maps the Secret → Deployment → Pods → ServiceAccount chain, recognising credentials from Vault, External Secrets, Argo CD, cert-manager, Helm and plain Opaque secrets. It is a 0.1.x proof of concept and needs FreeLens v1.9.0 or later, so use it for triage rather than as your expiry alerting.

Are FreeLens extensions installed per cluster or per user?

Per user. An installed extension is available across every cluster in your catalogue. If a cluster lacks the CRDs an extension depends on — Flux, Karpenter, Gateway API — its views stay empty on that cluster instead of failing.

Which FreeLens extension is the most widely used?

The FluxCD extension, by a clear margin. It surfaces Kustomizations, HelmReleases, GitRepositories and their reconciliation state as first-class views, which is the single biggest gap in stock FreeLens for teams running GitOps.

Further Reading

Kubernetes Features That Hurt in Production: A Framework for Safer Adoption

Kubernetes Features That Hurt in Production: A Framework for Safer Adoption

If you manage Kubernetes in production, you’ve likely felt the sting of a feature that promised stability but delivered chaos. The community is rich with stories of PodDisruptionBudgets (PDBs) that blocked critical updates, misconfigured liveness probes that created restart loops, and resource limits that turned a simple deployment into a cascading failure. These aren’t inherently “bad” features; they are powerful tools that, like a surgeon’s scalpel, require precise understanding and context to use effectively.

Related reading: liveness probe anti-patterns.

Drawing from a wealth of shared experience—including community discussions and documented failure stories—a clear pattern emerges. The gap between a feature’s theoretical promise and its production reality is often bridged not by more documentation, but by operational rigor. This post analyzes common pitfalls, not to discourage the use of these features, but to provide a decision framework for platform teams to evaluate adoption, focusing on observability, gradual rollout, and clear rollback plans.

The Gap Between Theory and Practice: Features That Bite Back

Kubernetes is designed to automate complex distributed systems patterns. However, this automation can amplify misconfigurations at scale. The following features are frequently cited as sources of production pain, precisely because their power is double-edged.

1. PodDisruptionBudgets (PDBs): The Update Blocker

On paper, a PDB is a safeguard. It ensures a minimum number of pods for a critical application remain available during voluntary disruptions like node drains or cluster upgrades. The theory is flawless.

The practice, as shared by many engineers, reveals the trap: a PDB with overly restrictive minAvailable or maxUnavailable settings can completely halt cluster maintenance. Imagine a deployment with 3 pods and a PDB set to minAvailable: 3. Any drain operation is now impossible, stalling node security patches or Kubernetes version upgrades. The cluster’s ability to heal and evolve is held hostage by a configuration intended to protect it.

The deeper lesson isn’t to avoid PDBs, but to configure them with the system’s evolution in mind. They must allow for the cluster’s own lifecycle operations.

2. Liveness and Readiness Probes: The Self-Inflicted Outage

Probes are the cornerstone of Kubernetes’ self-healing and traffic management. A liveness probe failure restarts the pod; a readiness probe failure removes it from service endpoints. This is essential for resilience.

In production, misconfigured probes are a classic source of instability. Common pitfalls include:

  • Overly sensitive liveness checks: A probe checking an endpoint that briefly spikes in latency due to a downstream cache miss can cause a restart loop, exacerbating the problem and taking the service fully down.
  • Resource-intensive probes: A probe that executes a heavy database query every few seconds can itself become a source of resource exhaustion and latency, creating a feedback loop of failure.
  • Incorrect readiness signals: An application marked “not ready” during its entire startup or lengthy initialization will never receive traffic, appearing as a deployment failure.

As noted in the Kubernetes configuration overview, probes must be designed to reflect the actual health of the application, not an idealized state. They should be cheap, stable, and representative.

3. Resource Requests and Limits: The Silent Strangulation

Setting CPU and memory requests/limits is Kubernetes 101. They ensure fair scheduling and prevent a single pod from consuming all node resources. The theory is fundamental to multi-tenancy.

The production reality is subtler. Setting limits too low (“limit starvation”) is a frequent cause of mysterious, intermittent failures. A pod hitting its CPU limit is throttled, causing increased latency and timeouts. A pod hitting its memory limit is OOMKilled instantly. The symptoms—slow responses or disappearing pods—often point to application bugs, masking the true infrastructure cause.

Conversely, setting requests too high leads to poor cluster utilization and scheduling headaches. The key is continuous observation: limits should be informed by actual usage under load, not initial guesses.

4. Helm Hooks and Complex Operators: The Unpredictable Orchestrator

Helm hooks and custom operators automate complex lifecycle tasks: database migrations, secret injection, or pre-upgrade validation. They abstract away imperative steps.

In production, this abstraction can become a black box. A post-install hook that fails can leave a release in a stuck state. An operator with a bug in its reconciliation logic can enter a loop, endlessly creating and deleting resources. The complexity of debugging an automated system that has gone awry often far exceeds the complexity of the manual process it replaced. The failure stories aggregated in resources like kubernetes-failure-stories are replete with examples of automation gone wrong.

A Framework for Safer Feature Adoption

Banning powerful features is not the answer. The goal is to adopt them with eyes wide open. Platform teams should implement a framework that evaluates risk and mandates safeguards. Here is a practical, four-phase approach.

Phase 1: Evaluation & Contextual Understanding

Before enabling a feature cluster-wide or recommending it to application teams, ask:

  • What problem does this solve for us? Is it a real pain point, or just a “nice-to-have”?
  • What is the failure mode? How can this feature break? (e.g., PDBs block drains, probes cause restarts).
  • What are the observability requirements? What metrics, logs, and alerts do we need to see if it’s misbehaving?
  • What is the rollback procedure? How do we quickly disable or revert this feature if it causes an incident?

Phase 2: Implementation with Guardrails

Deploy the feature with constraints that limit its blast radius.

  • Start with non-critical workloads: Apply PDBs first to staging or low-priority services.
  • Use sane defaults via Policy-as-Code: Use tools like OPA/Gatekeeper or Kyverno to enforce safe defaults. For example, a policy could forbid PDBs with minAvailable: 100% or enforce a maximum probe timeout.
  • Document the “why” and the “how to escape”: Annotate resources or maintain runbooks that explain the configuration and the steps to neutralize it in an emergency.

Phase 3: Gradual Rollout & Observability

Treat feature adoption like a software deployment.

  • Canary the configuration: Apply a new PDB or aggressive probe to one pod or one namespace first. Monitor its effect closely.
  • Implement specific monitoring: Beyond general cluster health, create alerts for:
    – PDBs blocking evictions for > X minutes.
    – Pod restart counts spiking (potential probe issue).
    – Containers hitting CPU throttling or being OOMKilled.
    – Helm releases stuck in a pending hook state.

A simple Prometheus alert for PDB blockage might look like this:

# Alert if a PDB is blocking voluntary pod disruptions for too long
- alert: PDBBlockingDisruption
  expr: kube_poddisruptionbudget_status_current_healthy == kube_poddisruptionbudget_status_desired_healthy
    and (kube_poddisruptionbudget_status_desired_healthy - kube_poddisruptionbudget_status_expected_pods) == 0
    and kube_poddisruptionbudget_status_disruptions_allowed == 0
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "PDB {{ $labels.namespace }}/{{ $labels.poddisruptionbudget }} is blocking all pod disruptions"
    description: "The PDB requires all pods to be available, preventing node drains or updates for 10 minutes."

Phase 4: Review and Iteration

Adoption isn’t a one-time event. Regularly review:

  • Are the features providing the intended value? Are PDBs actually increasing availability during updates?
  • What incidents or near-misses have they been involved in? Use post-incident reviews to refine configurations and policies.
  • Can we improve defaults or abstractions? Can the platform team provide a simplified, safe Custom Resource or Helm chart that encapsulates best practices?

Frequently Asked Questions

Which Kubernetes features cause the most production incidents?

The recurring offenders are the ones that act automatically on your behalf: PodDisruptionBudgets that block node drains forever, liveness probes that restart healthy pods under load, and aggressive affinity rules that make workloads unschedulable. None of them are bad features — they hurt when adopted with defaults copied from a tutorial instead of settings derived from your workload.

Why is a PodDisruptionBudget risky if it protects availability?

Because a PDB with maxUnavailable: 0 (or a selector matching a single replica) makes voluntary disruption impossible: node drains hang, upgrades stall, and cluster maintenance turns into manual pod deletion at 2 AM. A PDB must always leave the cluster a legal way to move your pods.

Should I avoid these features altogether?

No — the article’s point is the opposite. Adopt them deliberately: understand the failure mode each feature introduces, test that failure mode (drain a node, kill a replica) before production, and roll out with conservative settings you tighten over time. Power tools, respected.

How do I evaluate a Kubernetes feature before adopting it?

Four questions: What does it do automatically and when? What is the failure mode when it misfires — and does it fail open or closed? Can I observe it acting (events, metrics)? And can I roll it back under pressure? If you cannot answer all four, you are adopting a behavior, not a feature.

Conclusion: Embrace Power, Respect Complexity

The history of engineering is the history of building more powerful tools and learning to wield them safely. Kubernetes features like PDBs, probes, and resource management are no different. Their potential for causing production pain is a direct reflection of their power to automate complex, critical behaviors.

The path forward is not avoidance, but disciplined adoption. By shifting from a mindset of “enable and hope” to a framework of “evaluate, guard, observe, and iterate,” platform teams can harness these powerful features to build more resilient, self-healing systems without becoming victims of their own automation. The lessons are already written in the community’s failure stories; the task is to learn from them and build a safer, more informed practice.

Kubernetes Namespace Isolation: When Security Boundaries Fail

Kubernetes Namespace Isolation: When Security Boundaries Fail

If you manage multi-tenant Kubernetes clusters or enforce strict separation between development, staging, and production environments, you likely rely on namespaces as a primary security boundary. It’s a logical, clean model: workloads and users are isolated within their designated namespaces, creating a clear perimeter for access control and network traffic.

Related reading: the 2026 Kubernetes hardening guide.

But what if this boundary is more of a suggestion than a hard wall? The reality is that namespace isolation, as defined by the Kubernetes security model, is a construct built on the correct configuration of several underlying controls. When one of those controls fails—often through misconfigured platform-level tools—the entire boundary can collapse, enabling lateral movement that compromises the security of your entire cluster.

This isn’t a theoretical concern. Recent vulnerabilities, like the one detailed in a discussion on CVE-2026-22039, demonstrate how admission controllers, which are meant to enforce security, can be exploited to bypass namespace isolation entirely. This event isn’t an anomaly; it’s a symptom of a broader pattern where the complexity of the platform layer introduces critical gaps in a core security tenet.

The Illusion of the Hard Boundary

Kubernetes documentation is clear: “Namespaces are a way to divide cluster resources between multiple users.” They are a mechanism for scoping names and organizing objects. However, they are not a security feature by themselves. Isolation is achieved through the combination of:

  • RBAC (Role-Based Access Control): Governing who can do what, and where.
  • Network Policies: Controlling pod-to-pod communication.
  • Admission Controllers: Validating and mutating requests before persistence.
  • Resource Quotas & Limit Ranges: Managing resource consumption.

The security boundary exists only when all these layers are correctly configured and aligned. A flaw in any one layer—especially in a cluster-scoped component like an admission controller, a monitoring agent, or a service mesh sidecar injector—can create a bridge between namespaces.

How the Boundary Fails: Real-World Exploit Paths

Let’s examine common failure modes that break namespace isolation, moving from conceptual to concrete.

1. Privileged Admission Controller Exploits

The CVE-2026-22039 discussion highlights a classic case. An admission controller with broad permissions (e.g., cluster-admin or powerful ClusterRole bindings) is deployed to validate resources. If this controller has a vulnerability—such as improperly validating the requesting user or namespace of the mutated object—an attacker could craft a request that tricks the controller into creating or modifying resources in a namespace they should not have access to.

Attack Flow:

  1. Attacker has compromised a pod in the tenant-a namespace.
  2. They discover a cluster-scoped admission controller (e.g., a policy engine) is vulnerable to a confused deputy attack.
  3. They send a malicious payload to the API server that triggers the admission controller.
  4. The admission controller, operating with high privileges, is tricked into creating a privileged ServiceAccount or a pod with host network access in the kube-system namespace.
  5. Isolation is broken; lateral movement to a critical namespace is achieved.

2. Misconfigured Network Policies (or Their Absence)

By default, Kubernetes networking allows all pods to communicate with each other, regardless of namespace. Without Network Policies, a compromised pod in dev can directly probe and attack pods in production. Even with policies, a single overly permissive rule (e.g., allowing ingress from all namespaces for a debugging port) can create a breach.

3. Over-Permissioned Service Accounts & Pods

ServiceAccounts are namespaced, but the RBAC Roles or ClusterRoles bound to them are not. A common misconfiguration is binding a namespaced ServiceAccount to a powerful ClusterRole. A pod using that ServiceAccount effectively has those cluster-wide privileges, allowing it to read secrets, delete pods, or create bindings in any namespace.

A Framework for Testing Namespace Boundaries

Assuming isolation is dangerous. You must actively test it. Here is a practical framework for platform and security teams.

Phase 1: Discovery & Mapping

  • Inventory Cluster-Scoped Components: List all deployments, daemonsets, and pods in kube-system, gatekeeper-system, istio-system, etc. Document their assigned ServiceAccounts and associated RBAC.
  • Audit RBAC Bindings: Use kubectl get clusterrolebindings -o wide and kubectl get rolebindings --all-namespaces to find any bindings of powerful roles to ServiceAccounts or users in non-critical namespaces.
  • Map Network Policies: Generate a visual or logical map of allowed ingress/egress flows between namespaces. Identify namespaces with no policies applied.

Phase 2: Active Penetration Testing

From the perspective of a compromised pod in a non-privileged namespace (simulate with a benign test pod), run controlled tests.

Test for Privilege Escalation:

# Inside the test pod
# 1. Check the pod's own permissions
kubectl auth can-i --list

# 2. Attempt to list resources in other namespaces
kubectl get pods -n kube-system
kubectl get secrets -n production

# 3. Attempt to create a pod in another namespace
cat <<EOF | kubectl apply -f - --namespace=kube-system
apiVersion: v1
kind: Pod
metadata:
  name: test-breakout
spec:
  containers:
  - name: busybox
    image: busybox
    command: ['sh', '-c', 'sleep 3600']
EOF

Test for Network Access:

# Use netcat or curl to probe internal services in other namespaces
# Find the ClusterIP of a service in the target namespace
kubectl get svc -n production --output=wide

# From test pod, attempt to connect
curl -v http://<production-service-cluster-ip>:<port>
nc -zv <production-service-cluster-ip> <port>

Phase 3: Admission Controller Stress Testing

This is more advanced but critical. Craft resource manifests designed to probe the logic of your policy engines (e.g., OPA Gatekeeper, Kyverno, custom webhooks).

  • Submit requests with mismatched or spoofed namespace fields in the object metadata versus the request path.
  • Attempt to create objects that reference resources (like ConfigMaps or Secrets) in other namespaces.
  • Test if controllers correctly validate the userInfo (username, groups) of the requester in their decision-making logic.

Hardening the Boundary: Defensive Controls

Testing reveals gaps; these controls close them.

1. Implement Zero-Trust Networking

Default-deny is the only sane starting point. Apply a baseline NetworkPolicy to every namespace that denies all ingress from other namespaces.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-cross-namespace
  namespace: <protected-namespace>
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector: {} # Only allow from pods in the SAME namespace

Explicitly allow required cross-namespace communication (e.g., from monitoring or service mesh namespaces) using namespaceSelector rules, not open CIDR blocks.

2. Principle of Least Privilege for Platform Tools

Scrutinize the RBAC for every cluster-scoped component. Does your admission controller really need create permission on pods cluster-wide? Or can its role be scoped to specific namespaces or resource types? Use tools like kubectl audit or third-party RBAC analyzers to find and reduce over-permissive bindings.

3. Namespace-as-a-Boundary for ServiceAccounts

Never bind a namespaced ServiceAccount to a ClusterRole unless it is absolutely necessary. If a component needs to act in multiple namespaces, create separate ServiceAccounts and RoleBindings in each namespace, or use a tightly scoped ClusterRole that only lists the required namespaced resources.

Monitoring for Boundary Violations

Detection is your last line of defense. Monitor the Kubernetes API audit logs for tell-tale signs of boundary crossing.

  • Unauthorized Cross-Namespace API Calls: Alert on create, update, or get requests where the requesting user/service account’s namespace differs from the target object’s namespace, and the action is not explicitly allowed by a known pattern (e.g., cluster-admin, system components).
  • Network Policy Violation Attempts: If using a CNI that supports it (like Cilium), export flow logs and alert on denied connection attempts from one namespace to a sensitive another.
  • Admission Controller Anomalies: Monitor the logs of your admission webhooks for a high rate of denials or errors, which could indicate probing or exploit attempts.

Frequently Asked Questions

Are Kubernetes namespaces a security boundary?

Not by default. A namespace is a logical grouping for names, quotas and RBAC scoping — but the network is flat, and nothing stops a pod in one namespace from calling a Service in another. Namespaces only become a security boundary when you actively enforce one: default-deny NetworkPolicies, scoped RBAC, and admission controls.

Can pods in different namespaces communicate by default?

Yes — any pod can reach any other pod or Service across namespaces out of the box (service.other-ns.svc.cluster.local). If your threat model assumes tenant separation, that default is the first thing to close with a default-deny NetworkPolicy per namespace.

What commonly escapes namespace isolation?

Cluster-scoped resources (nodes, CRDs, ClusterRoles), anything with host access (hostPath, hostNetwork, privileged pods), the shared kernel itself, and RBAC grants that quietly cross namespaces (ClusterRoleBindings). Auditing those is more valuable than adding more controls inside the namespace.

How do I actually harden namespace boundaries?

Layered: default-deny NetworkPolicy (ingress and egress) per namespace, Roles instead of ClusterRoles wherever possible, ResourceQuota and LimitRange to contain blast radius, Pod Security Admission at restricted, and periodic boundary testing — the article’s framework — to verify the isolation you think you have actually holds.

Conclusion: Isolation as an Active Discipline

Namespace isolation in Kubernetes is not a static configuration you set and forget. It is a dynamic security property that must be continuously validated, enforced, and monitored. The platform’s complexity and extensibility, while powerful, are the very factors that introduce fragility into this boundary.

The deprecation of a component like Ingress-NGINX teaches us to build frameworks for change. Similarly, breaches of namespace isolation teach us that security is not a feature of a single object (the namespace), but an emergent property of the entire system’s configuration. Your cluster’s security boundary is only as strong as the weakest link in the chain of RBAC, network policies, and admission control. Treat it as a critical, living part of your platform—one that demands proactive testing, rigorous hardening, and vigilant observation.