Scale Kubernetes deployments to zero using KEDA - Mijndert Stuij
Scale Kubernetes deployments to zero using KEDA
June 3, 2026
If you have a multi-cluster EKS deployment for your development, QA and staging environments, those generally don't need to run at 3AM. No one is using any of that, except for maybe a few cronjobs to refresh data. Everything else is wasted idle compute.
The math is simple. A dev cluster running 24/7 bills for 168 hours a week. Trim it to office hours, weekdays only, and you're down to roughly 55. That's about two thirds of the compute spend on every environment nobody actively uses, and it stacks per cluster. For us that's dev, QA and staging.
The stock HPA can't really help with this; it scales on load, and its hard floor is one replica. It can't take your workloads to zero.
There are multiple ways to scale down your deployments to zero to make sure you don't waste that money. Recently I started using KEDA and a cron scaler to do the work for me.
KEDA is short for Kubernetes Event-Driven Autoscaling. It's an intelligent autoscaler that can integrate with Prometheus, VictoriaMetrics and many other more specialized scalers which allows it to scale based on, among many others, Kafka lag, queue depth, Prometheus queries, and the one we want: cron .
You install KEDA on your cluster once, and then define scaling intent using a scaledObject.
Before you start
A few versions and assumptions to check:
ArgoCD 2.5 or newer, so the ServerSideApply=true sync option exists.
Your target Deployments should not pin replicas: in their Helm chart. If they do, ArgoCD will set it back on every sync and the ignoreDifferences rule below won't save you. Drop the field and let the HPA own it.
Deploying KEDA using ArgoCD
KEDA ships an official Helm chart and installing it using ArgoCD is just one Application away:
apiVersion: argoproj.io/v1alpha1<br>kind: Application<br>metadata:<br>name: keda<br>namespace: argocd<br>spec:<br>project: platform<br>destination:<br>server: https://kubernetes.default.svc<br>namespace: keda<br>source:<br>repoURL: https://kedacore.github.io/charts<br>chart: keda<br>targetRevision: 2.20.0<br>helm:<br>values: |<br>crds:<br>install: true<br>syncPolicy:<br>automated:<br>selfHeal: true<br>prune: false<br>syncOptions:<br>- CreateNamespace=true<br>- ServerSideApply=true<br>That syncOptions block is doing a bit of heavy lifting. A few gotchas to go through:
Gotcha 1: CRDs need server-side apply
KEDA installs a handful of CRDs (ScaledObject, ScaledJob, TriggerAuthentication). Argo CD's default client-side apply puts a last-applied-configuration field into an annotation, and chunky CRDs can blow past Kubernetes' 256 KB annotation limit causing your sync to fail with metadata.annotations: Too long. ServerSideApply=true sidesteps it entirely by letting the API server own field merging.
Gotcha 2: The kube-system RoleBinding that breaks restrictive projects
KEDA's metrics adapter (keda-operator-metrics-apiserver) is an aggregated API server; it serves external.metrics.k8s.io, which is how the HPA reads KEDA's computed metrics. Per the Kubernetes aggregation-layer contract, every extension API server must read the extension-apiserver-authentication ConfigMap in kube-system. So the chart creates exactly one resource there:
kind: RoleBinding<br>metadata:<br>name: keda-operator-auth-reader<br>namespace: kube-system<br>roleRef:<br>kind: Role<br>name: extension-apiserver-authentication-reader<br>If your AppProject restricts destination namespaces (and it should), the sync fails:
namespace kube-system is not permitted in project '...'<br>The tempting fix is to add kube-system to your shared application project. But then every application can write to kube-system so instead, give platform-specific charts their own, more privileged project:
apiVersion: argoproj.io/v1alpha1<br>kind: AppProject<br>metadata:<br>name: platform<br>namespace: argocd<br>spec:<br>description: Cluster platform applications with elevated NS access.<br>sourceRepos:<br>- https://kedacore.github.io/charts<br>destinations:<br>- server: https://kubernetes.default.svc<br>namespace: keda<br>- server: https://kubernetes.default.svc<br>namespace: kube-system<br>clusterResourceWhitelist:<br>- group: "*"<br>kind: "*"<br>Gotcha 3: Stop ArgoCD from fighting KEDA
Once KEDA scales your deployments to a set amount based on metrics or in this case a cron schedule, by default ArgoCD will try to reconcile with the known state. The fix is to add an ignoreDifferences rule on the Application (or templated in your ApplicationSet) for the replica field:
ignoreDifferences:<br>- group: apps<br>kind: Deployment<br>jsonPointers:<br>- /spec/replicas<br>The cron scaler
A cron trigger defines a window. Inside it you get desiredReplicas; outside every window, KEDA scales the target to minReplicaCount. Set that to 0 and the workload disappears when idle:
apiVersion: keda.sh/v1alpha1<br>kind: ScaledObject<br>metadata:<br>name: internal-dashboard<br>namespace: default<br>spec:<br>scaleTargetRef:<br>name: internal-dashboard # the Deployment<br>minReplicaCount: 0 # enables scale-to-zero<br>maxReplicaCount: 1<br>cooldownPeriod: 300...