Kubernetes HPA Setup: 13 Steps, 60 Min [2026]

Your traffic doubles at 2 a.m., and the pods that handled yesterday’s load start timing out. Kubernetes HPA fixes that without a pager. The HorizontalPodAutoscaler v2 API reference describes the Horizontal Pod Autoscaler as a controller that changes the replica count of any resource implementing the scale subresource. This tutorial walks through 13 steps, about 60 minutes of hands-on work, and ends with a working lab you can rebuild from one script.

The tutorial targets Kubernetes 1.37, the release line that shipped on August 26, 2026, and the one that moved HPA scale-to-zero to beta. If you already scale game workloads, our guide to autoscaling game servers with Kubernetes HPA and KEDA covers that niche. This article takes the general-purpose route: a web service, a queue worker, and the node layer underneath them.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

What You Will Build in This Kubernetes HPA Tutorial

The lab runs on a local three-node cluster created with kind, so it costs nothing and you can break it freely. You will deploy a CPU-hungry sample web app, attach a Horizontal Pod Autoscaler to it, hammer it with synthetic traffic, and watch replicas climb and fall. Then you will tune how fast the scaling reacts, add KEDA for a queue-driven worker that sits at zero pods when idle, and finish with the node-autoscaling commands for GKE, AKS and EKS.

Three layers of autoscaling exist in a real cluster, and people mix them up constantly. Pod-level horizontal scaling adds or removes replicas. Pod-level vertical scaling resizes the CPU and memory requests of each replica. Node-level scaling adds or removes the machines that run those pods. This article covers the first and third in depth, touches the second, and shows where KEDA fits as an event-driven front end to the first.

If you are still deciding whether Kubernetes is the right orchestrator for a small team, read our Kubernetes vs Docker Swarm comparison first. Autoscaling is one of the strongest arguments for Kubernetes, and also one of the easiest features to misconfigure.

By the end you will have these working pieces:

  • A kind cluster with metrics-server, so kubectl top and the HPA both get live CPU numbers.
  • A sample Deployment with correct resource requests, the single most common thing people forget.
  • An imperative HPA from kubectl autoscale, then a declarative autoscaling/v2 manifest with scale-up and scale-down behavior rules.
  • KEDA installed through Helm, with a Redis-backed worker that scales from zero to ten pods and back.
  • A verification checklist, 8+ troubleshooting fixes and a single script that rebuilds the whole lab.

Prerequisites and Versions for Kubernetes HPA

You need a laptop or VM with Docker running, about 8 GB of free RAM and a terminal. Everything else installs in the first steps. The table lists what this tutorial was written against. Where a version is not pinned, check the project’s release page and use the newest stable build.

ComponentVersion usedWhy it matters
Kubernetes1.37 (released August 26, 2026)HPA scale-to-zero is beta and on by default in this release
kubectlWithin one minor version of the clusterSkewed clients hide new HPA fields
kindLatest releaseRuns the cluster as Docker containers
DockerLatest stable enginekind needs a running container runtime
HelmLatest 3.x or newerInstalls metrics-server and KEDA
metrics-serverLatest releaseFeeds CPU and memory data to the HPA
KEDA2.21.0 (released September 23, 2026)Event-driven scaling and scale-to-zero
Cloud CLIs (optional)gcloud, az, awsOnly needed for Step 12

The Kubernetes 1.37 line matters for one specific reason. According to the Kubernetes release blog, HPA scale-to-zero graduated to beta in 1.37 and is enabled by default. In 1.36, released April 22, 2026, the same feature was alpha and disabled by default. If your managed cluster still runs 1.36 or older, the native zero-replica path in this article will not work, and KEDA is your route. Our Kubernetes 1.37 upgrade guide lists the breaking changes to check before you move a production cluster.

KEDA 2.21.0 is the newest core release at the time of writing, according to Ivinco’s autoscaling overview. The release adds dynamic HPA targets for external scalers, Prometheus OAuth2 client-credentials authentication and Kafka Kerberos credential-cache support. The release notes also reportedly list three breaking changes, so anyone upgrading from the 2.20 line should read the upgrade notes before touching a live cluster. The Vertical Pod Autoscaler also shipped a 1.8.0 release on September 23, 2026, which you can confirm on the kubernetes/autoscaler release page.

Check your tooling before you start:

docker version --format '{{.Server.Version}}'
kind version
kubectl version --client
helm version --short

If any command fails, install that tool from its official documentation and rerun the block. Do not continue with a missing Docker daemon, because the next step will fail with a confusing socket error.

How the Kubernetes HPA Math Works

Before you type a manifest, learn the one formula that explains almost every HPA surprise. The controller compares the current metric value to the target and scales the replica count by that ratio, rounding up:

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

The Kubernetes documentation gives two worked examples. If the current metric is 200.0 and the target is 100.0, the ratio is 2.0 and the replica count doubles. If the current metric is 50.0 against the same target, the ratio is 0.5 and the replica count halves. The controller skips any scaling action when the ratio sits close enough to 1.0, within a tolerance that defaults to 0.1, which is 10%. That dead band stops the HPA from twitching on every small fluctuation.

Why CPU requests drive everything

Utilization is not raw CPU usage. The documentation defines it as the ratio between the current usage of a resource and the amount the pod requested. A pod that requests 200 millicores and uses 100 millicores sits at 50% utilization. A pod with no CPU request has no denominator, so the HPA cannot compute anything and reports an unknown target. This single rule causes more broken autoscalers than any bug in Kubernetes.

Startup, readiness and the stabilization window

New pods do not report useful CPU the moment they start. The controller handles not-yet-ready pods conservatively. On a scale-up it assumes those pods consume 0% of the desired value, and on a scale-down it assumes they consume 100%. Both assumptions dampen the swing and prevent overshoot. The kube-controller-manager flag --horizontal-pod-autoscaler-cpu-initialization-period defaults to 5 minutes, and during that window the controller treats early CPU samples with suspicion.

Scale-down gets its own brake. The flag --horizontal-pod-autoscaler-downscale-stabilization defaults to 5 minutes, and the controller considers every desired state from that window before it removes a replica. If you load-test and then wonder why pods linger after the traffic stops, this is the reason. You can override the window per HPA with the behavior field, which Step 8 covers.

Build the Lab Cluster (Steps 1 to 3)

Step 1: Create a three-node kind cluster

Save this file as kind-hpa-lab.yaml. One control-plane node and two workers give the scheduler room to spread replicas, which makes the scale-up visible.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker

Create the cluster. Each kind release ships with a default node image, so pick a kind release whose default image is Kubernetes 1.37. If you need a specific build, read the kind release notes and pass the matching tag with --image rather than guessing a tag.

kind create cluster --name hpa-lab --config kind-hpa-lab.yaml
kubectl cluster-info --context kind-hpa-lab
kubectl get nodes

Expected output from the last command (ages will differ):

NAME                    STATUS   ROLES           AGE   VERSION
hpa-lab-control-plane   Ready    control-plane   62s   v1.37.x
hpa-lab-worker          Ready    <none>          42s   v1.37.x
hpa-lab-worker2         Ready    <none>          42s   v1.37.x

Step 2: Install metrics-server

The HPA reads CPU and memory from the Metrics API, and metrics-server provides it. kind kubelets use self-signed certificates, so the lab needs the --kubelet-insecure-tls flag. Never carry that flag into a production cluster.

helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/
helm repo update
helm upgrade --install metrics-server metrics-server/metrics-server \
  --namespace kube-system \
  --set args={--kubelet-insecure-tls}
kubectl -n kube-system rollout status deployment/metrics-server

Give it about a minute to scrape the first samples, then confirm that numbers appear:

kubectl top nodes
NAME                    CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
hpa-lab-control-plane   142m         3%       711Mi           9%
hpa-lab-worker          38m          0%       215Mi           2%
hpa-lab-worker2         35m          0%       204Mi           2%

If you see error: Metrics API not available, wait another minute and retry. Persistent failures appear in the troubleshooting section below.

Step 3: Deploy the sample app with resource requests

The Kubernetes project publishes a small PHP image that burns CPU on every request, which makes it ideal for autoscaling demos. Save the following as app.yaml. Notice two details. The container sets a CPU request, which the HPA needs as its denominator. And the Deployment omits spec.replicas on purpose, so the HPA owns the replica count from day one.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: php-apache
  labels:
    app: php-apache
spec:
  selector:
    matchLabels:
      app: php-apache
  template:
    metadata:
      labels:
        app: php-apache
    spec:
      containers:
        - name: php-apache
          image: registry.k8s.io/hpa-example
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 200m
              memory: 64Mi
            limits:
              cpu: 500m
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: php-apache
  labels:
    app: php-apache
spec:
  selector:
    app: php-apache
  ports:
    - port: 80
      targetPort: 80
kubectl apply -f app.yaml
kubectl rollout status deployment/php-apache
kubectl top pods

One pod runs at close to zero CPU. That is your baseline, and the starting point for every number you will watch in the next steps.

Create Your First Kubernetes HPA (Steps 4 to 6)

Step 4: Create the HPA with kubectl autoscale

The fastest route is imperative. The kubectl autoscale command creates an autoscaler for a Deployment, ReplicaSet, StatefulSet or replication controller. The kubectl reference notes that it tries the autoscaling/v2 API first and falls back to autoscaling/v1 if needed. The documentation’s own example uses the same pattern you are about to run: kubectl autoscale rs foo --min=2 --max=5 --cpu=80% creates an autoscaler for ReplicaSet foo with an 80% CPU target.

kubectl autoscale deployment php-apache --cpu=50% --min=2 --max=10
kubectl get hpa php-apache
NAME         REFERENCE               TARGETS              MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: <unknown>/50%   2         10        1          8s

The <unknown> value is normal for the first half minute. After a short wait the target fills in, and the replica count rises to the minimum of two:

NAME         REFERENCE               TARGETS       MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: 1%/50%   2         10        2          74s

Step 5: Inspect what the controller thinks

Make a habit of reading kubectl describe hpa. It reveals the controller’s reasoning through three status conditions.

kubectl describe hpa php-apache
Metrics:                                               ( current / target )
  resource cpu on pods  (as a percentage of request):  1% (2m) / 50%
Min replicas:                                          2
Max replicas:                                          10
Deployment pods:                                       2 current / 2 desired
Conditions:
  Type            Status  Reason              Message
  ----            ------  ------              -------
  AbleToScale     True    ReadyForNewScale    recommended size matches current size
  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

Read the three conditions in order. AbleToScale says the controller can change the target. ScalingActive says metrics arrive and make sense. ScalingLimited says the min or max bound is clamping the answer. When an HPA misbehaves, one of these three almost always flips and tells you which layer to investigate.

Step 6: Generate load and watch it scale

Open two terminals. In the first, watch the HPA:

kubectl get hpa php-apache --watch

In the second, start a throwaway pod that requests the service in a tight loop:

kubectl run load-generator --rm -i --tty --image=busybox:1.36 --restart=Never -- \
  /bin/sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done"

Within a minute or two the watch window shows CPU climbing past the target, followed by new replicas. Your exact numbers will differ, but the shape should resemble this:

NAME         REFERENCE               TARGETS        MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: 1%/50%    2         10        2          4m
php-apache   Deployment/php-apache   cpu: 118%/50%  2         10        2          4m30s
php-apache   Deployment/php-apache   cpu: 118%/50%  2         10        5          4m45s
php-apache   Deployment/php-apache   cpu: 71%/50%   2         10        5          5m15s
php-apache   Deployment/php-apache   cpu: 49%/50%   2         10        6          5m45s

Run the formula on the second data row. Divide the current CPU value by the target to get the ratio, multiply the current replica count by that ratio, and round up. That matches the jump you see. Stop the load with Ctrl+C in the second terminal. CPU drops within seconds, but the replica count stays high for the stabilization window, then steps down. That delay is a feature, not a bug.

Write a Production-Style autoscaling/v2 HPA (Steps 7 and 8)

Step 7: Move the HPA into a manifest

Imperative commands are fine for experiments, but a real team keeps autoscaling in Git next to the Deployment. Save this as hpa-v2.yaml. The autoscaling/v2 API is the current one. The older autoscaling/v2beta2 is no longer current, so any tutorial that still uses it is out of date.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50
kubectl apply -f hpa-v2.yaml
kubectl get hpa php-apache -o yaml | head -30

Applying over the HPA you made in Step 4 works, and kubectl prints a harmless warning about a missing last-applied annotation. If you prefer a clean slate, run kubectl delete hpa php-apache first.

You can stack several metrics in one HPA. When you list more than one, the controller computes a desired replica count for each metric and picks the highest. That rule is why a forgotten memory target can pin a Deployment at a high replica count long after CPU settles. Add extra metrics only when you can explain why each one deserves a vote.

Step 8: Tune scale-up and scale-down behavior

The default behavior suits many workloads, but a user-facing API usually wants to scale up fast and scale down slowly. The behavior block gives you that control. It lets you set a stabilization window and rate-limiting policies for each direction. Replace the file with this tuned version:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 10
          periodSeconds: 60

Here is what each part does:

  • scaleUp window of 0 seconds: the controller acts on a spike immediately instead of waiting.
  • selectPolicy Max: when two scale-up policies disagree, the one allowing the bigger change wins, so the app can double or add four pods per minute, whichever is larger.
  • scaleDown window of 300 seconds: the controller looks back five minutes and uses the highest recommendation in that window, which smooths out short dips.
  • Percent policy of 10 per 60 seconds: at most 10% of current replicas leave each minute. The docs show that with 72 replicas, 10% is 7.2, and the number rounds up to 8.
kubectl apply -f hpa-v2.yaml
kubectl describe hpa php-apache | sed -n '/Behavior/,/Conditions/p'

Run the load generator from Step 6 again and compare. The scale-up phase should look similar, since the defaults already allow quick growth. The big change appears after you stop the load. Pods now leave in small steps instead of falling off a cliff, which protects you from the classic pattern where an HPA kills capacity just before the next wave arrives.

One more knob deserves a mention. The Kubernetes docs describe a per-HPA tolerance, with an example of a 5% scale-up tolerance on a memory target of 100MiB, so scaling triggers only above 105MiB. If you do not set it, the cluster-wide default of 10% applies. Check the feature status for your Kubernetes version before relying on it, because configurable tolerance has sat behind a feature gate in earlier releases.

Add KEDA for Event-Driven Scaling and Scale-to-Zero (Steps 9 to 11)

CPU is a lagging signal. By the time a worker pool burns CPU, the queue is already deep. KEDA scales on the signal that matters, such as queue length, and it can take a Deployment to zero pods when nothing needs doing. The KEDA documentation describes the project as event-driven autoscaling for Kubernetes workloads, and it adds a layer on top of the HPA rather than replacing it. KEDA contributor Jorge Turrado put it plainly on the KubeFM podcast: “KEDA is relying totally on the HPA controller because it’s a well tested piece in Kubernetes. For us is better than reinventing the wheel.”

That design explains the architecture. KEDA’s operator watches your ScaledObject, feeds external metrics to the Kubernetes metrics pipeline, and manages an HPA for you. It handles the zero-to-one and one-to-zero transitions itself, then hands the one-to-N range to the HPA. For a deeper comparison with node-level tools, see our breakdown of Karpenter vs Cluster Autoscaler vs KEDA.

Step 9: Install KEDA with Helm

helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm search repo kedacore/keda --versions | head -5
helm install keda kedacore/keda --namespace keda --create-namespace
kubectl -n keda get pods

The search command prints the chart versions on offer. This tutorial was written against KEDA 2.21.0, so pin the chart that ships that app version when you build something you plan to keep, using --version. The pod list should settle into three running pods:

NAME                                               READY   STATUS    RESTARTS   AGE
keda-admission-webhooks-6d9c7b5f4-x8k2p            1/1     Running   0          48s
keda-operator-7c84b6d9f5-q2m7t                     1/1     Running   0          48s
keda-operator-metrics-apiserver-5b7f9c6d8-h4v9r    1/1     Running   0          48s

Step 10: Deploy a Redis queue, a worker and a ScaledObject

The worker pops jobs from a Redis list, pretends each one takes two seconds and logs what it did. Save this as queue.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
spec:
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: redis
spec:
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: worker
spec:
  selector:
    matchLabels:
      app: worker
  template:
    metadata:
      labels:
        app: worker
    spec:
      containers:
        - name: worker
          image: redis:7-alpine
          command: ["/bin/sh", "-c"]
          args:
            - |
              while true; do
                job=$(redis-cli -h redis -p 6379 BLPOP jobs 5 | tail -n 1)
                if [ -n "$job" ]; then
                  echo "processing $job"
                  sleep 2
                fi
              done
          resources:
            requests:
              cpu: 50m
              memory: 32Mi

Now the ScaledObject. Save it as scaledobject.yaml:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker-scaler
spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 0
  maxReplicaCount: 10
  pollingInterval: 5
  cooldownPeriod: 30
  triggers:
    - type: redis
      metadata:
        address: redis.default.svc.cluster.local:6379
        listName: jobs
        listLength: "5"
kubectl apply -f queue.yaml
kubectl rollout status deployment/redis
kubectl apply -f scaledobject.yaml
kubectl get scaledobject worker-scaler
kubectl get deployment worker
NAME            SCALETARGETKIND      SCALETARGETNAME   MIN   MAX   READY   ACTIVE   AGE
worker-scaler   apps/v1.Deployment   worker            0     10    True    False    15s

NAME     READY   UP-TO-DATE   AVAILABLE   AGE
worker   0/0     0            0           40s

The worker sits at zero pods because the queue is empty, and ACTIVE reads False. No idle compute, no cost. The listLength value of 5 tells KEDA to aim for roughly five queued jobs per replica, so a backlog of 50 jobs asks for ten pods.

Step 11: Push jobs and watch zero become ten

In one terminal, watch the Deployment. In the other, push 100 jobs into the list.

# terminal 1
kubectl get deployment worker --watch

# terminal 2
kubectl exec deploy/redis -- sh -c 'for i in $(seq 1 100); do redis-cli RPUSH jobs job-$i > /dev/null; done'
kubectl exec deploy/redis -- redis-cli LLEN jobs
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
worker   0/0     0            0           3m
worker   0/1     1            0           3m6s
worker   1/1     1            1           3m12s
worker   1/4     4            1           3m27s
worker   4/10    10           4           3m42s
worker   10/10   10           10          3m57s
...
worker   10/0    0            10          7m10s
worker   0/0     0            0           7m12s

The sequence tells the whole story. The first job wakes the worker from zero. KEDA reports a backlog of 100 jobs, the HPA that KEDA manages asks for the ten-replica cap, and the pods drain the queue. Once the list is empty and the 30-second cooldown passes, the Deployment returns to zero. Check the logs of any worker to confirm it processed real jobs:

kubectl logs deploy/worker --tail=5

If the pods vanished before you could read logs, push another batch and query them while the backlog is still draining. A complete lab for your own team would also add a dead-letter list and an alert on queue age, since autoscaling hides symptoms as easily as it fixes them.

Native scale-to-zero versus KEDA

Kubernetes 1.37 now lets a plain HPA reach zero, with limits. According to the official docs, a workload can scale to zero pods when you use object or external metrics and set spec.minReplicas: 0. CPU and memory targets cannot do this, which makes sense: resource metrics come from running pods, so zero pods leave nothing to measure. You also need something that publishes the external metric, and that is exactly the job KEDA does well. The practical rule for late 2026 is simple. Use KEDA when you want ready-made scalers for queues, streams and databases. Use the native beta path when you already run a metrics adapter and want fewer moving parts.

Add Node Autoscaling on GKE, AKS and EKS (Step 12)

Pods can only multiply if somewhere exists to run them. When the HPA asks for ten replicas and the cluster has room for six, four pods sit in Pending until a node appears. Node autoscaling closes that gap by watching for unschedulable pods, adding machines and later removing the underused ones. Kubernetes documentation describes node autoscaling as provisioning and consolidating nodes to match demand while cutting cost, and as creating and deleting the cloud resources that back each node. Your kind lab has no cloud API to call, so this step is a runbook for the three big managed platforms. Run the commands against a test cluster first.

Google Kubernetes Engine

GKE still distinguishes Autopilot from Standard mode, according to Google’s documentation as of October 26, 2026. Autopilot manages nodes for you. On Standard, you enable autoscaling per node pool. Our Google Kubernetes Engine setup guide covers cluster creation in detail.

gcloud container clusters update CLUSTER_NAME \
  --location=LOCATION \
  --node-pool=default-pool \
  --enable-autoscaling \
  --min-nodes=1 \
  --max-nodes=5

Google announced on October 20, 2026 that GKE gained built-in scale-to-zero support for HPA workloads and custom PromQL-based HPA scaling. Treat both as new features, and confirm availability on your release channel before you design around them.

Azure Kubernetes Service

AKS offers the classic cluster autoscaler per node pool, plus Node Auto-Provisioning, which documentation updated on October 26, 2026 describes through AKSNodeClass resources. Check the availability level and regional support of Node Auto-Provisioning before production use. Our walkthrough on how to set up Azure AKS builds the cluster these commands assume.

az aks update \
  --resource-group RESOURCE_GROUP \
  --name CLUSTER_NAME \
  --enable-cluster-autoscaler \
  --min-count 1 \
  --max-count 5

A deadline sits three days from this article’s publication date. Microsoft states that AKS removes Azure Linux 2.0 node images beginning October 31, 2026, and node pools still using them can no longer scale. An autoscaler cannot add nodes it cannot build. Upgrade to a supported Kubernetes version or migrate the pool to osSku AzureLinux3 before that date.

Amazon EKS

EKS offers more paths than the other two. According to AWS documentation dated October 8, 2026, EKS Auto Mode is the default fully managed capacity mode for new clusters created without capacity settings, and it provisions and scales EC2 capacity from pod requirements. Self-managed Auto Scaling Group capacity remains supported. If you run managed node groups, you set the group bounds yourself and then add Karpenter or Cluster Autoscaler to act on them, as our guide to setting up Karpenter for EKS autoscaling shows.

aws eks update-nodegroup-config \
  --cluster-name CLUSTER_NAME \
  --nodegroup-name NODEGROUP_NAME \
  --scaling-config minSize=1,maxSize=5,desiredSize=2

That command only moves the bounds. Without a node autoscaler running, nothing uses them. EKS also supports Kubernetes 1.37, which AWS reportedly released for the service on October 2, 2026, so upgrading ahead of scale-to-zero workloads is realistic. Our EKS 1.37 upgrade tutorial walks through that move.

PlatformNode autoscaling optionsEnable atWatch out for
GKEAutopilot (managed) or Standard node pool autoscalingNode poolSet realistic max nodes to cap spend
AKSCluster autoscaler or Node Auto-ProvisioningNode pool or clusterAzure Linux 2.0 images removed from October 31, 2026
EKSAuto Mode, Karpenter or Cluster AutoscalerCluster or node groupGroup bounds alone do not scale anything
kind (lab)NoneNot applicableFixed node count, so pods may sit Pending
Any cloudPair with HPA or KEDAWorkloadMissing pod requests break node sizing too

Prove the node layer reacts

On a cloud test cluster, scale the sample Deployment past the cluster’s free capacity and watch the pending count. Raise maxReplicas and request more CPU per pod if needed. Then run kubectl get pods --field-selector=status.phase=Pending --watch in one terminal and kubectl get nodes --watch in another. A healthy setup shows pending pods, a new node joining within a few minutes, and the pending count falling to zero. If pods stay pending with no new node, jump to the troubleshooting list below.

Verify, Clean Up and Ship (Step 13)

Finish the lab with a short checklist so you know the setup works for the right reasons, not by accident.

# 1. Metrics flow
kubectl top pods

# 2. HPA is healthy: look for ScalingActive=True
kubectl describe hpa php-apache | grep -A4 Conditions

# 3. KEDA objects are ready
kubectl get scaledobject

# 4. No pod is stuck
kubectl get pods -A --field-selector=status.phase=Pending

# 5. Events show real rescales
kubectl get events --field-selector reason=SuccessfulRescale

When you are done experimenting, delete the cluster. The command removes every container and local network that kind created:

kind delete cluster --name hpa-lab

Before you move any of this to production, write down three numbers for each service: the minimum replicas you can survive with, the maximum your budget allows and the CPU utilization at which latency starts to degrade. Those three numbers, not defaults copied from a blog, are your real HPA settings. For wider context on managed cluster options, browse our cloud computing coverage.

Kubernetes HPA vs VPA vs KEDA vs Node Autoscalers

Newcomers often ask which autoscaler to use. The honest answer is that you usually run several at once, each at its own layer. The table below sorts them by what they change and what signal they read.

ToolWhat it changesTypical signalShips with Kubernetes?Best for
HPAReplica countCPU, memory, custom or external metricsYes, built into the control planeStateless web services and APIs
VPACPU and memory requests per podObserved usage historyNo, a separately deployed add-onRight-sizing requests, batch jobs
KEDAReplica count, including zeroQueues, streams, databases, cronNo, installed with HelmWorkers and event-driven apps
Cluster AutoscalerNode count in a poolUnschedulable podsNo, a separate componentPredictable node groups
Karpenter or Auto ModeNode type and countPending pod requirementsNo, provider-specificMixed instance sizes on AWS

Two notes on VPA. First, the Kubernetes docs describe it as an add-on that you deploy yourself, and its stable API is autoscaling.k8s.io/v1. Second, VPA now has an InPlace update mode that applies supported resource changes without recreating the pod. The docs list it as alpha in VPA 1.7.0, requiring Kubernetes 1.33 or later with the InPlacePodVerticalScaling feature, so check the status in the 1.8.0 release before you depend on it.

The key safety rule: do not let HPA and VPA fight over the same metric. If both react to CPU, the HPA adds pods while the VPA resizes them, and the two loops chase each other. Run VPA in recommendation-only mode on workloads that the HPA scales on CPU, or scale the HPA on a custom metric instead.

Common Kubernetes HPA Pitfalls to Avoid

Most autoscaling incidents trace back to a short list of repeat mistakes. Read these before you ship.

1. Missing or wrong resource requests

No CPU request means no utilization percentage, and the HPA stays at unknown. A request set far too high means the HPA never sees pressure. A request set far too low means it scales out at the first sip of traffic. Measure real usage under load, then set requests close to the typical figure and the limit above the peak.

2. Hard-coding replicas in the Deployment

If your Deployment manifest carries replicas: 3, a later edit or a replace operation can reset the count and fight the HPA. Delete the field once an HPA owns the workload. GitOps tools make this worse, because they enforce the Git state on every sync. Configure them to ignore the replicas field, or remove it from the manifest.

3. Two autoscalers on one workload

KEDA creates and manages its own HPA. If you also keep a hand-written HPA that targets the same Deployment, the two controllers issue conflicting scale commands and the replica count flaps. Pick one owner per workload. If you want CPU and a queue signal together, add a CPU trigger to the ScaledObject instead of keeping a second HPA.

4. Scaling on memory for runtimes that never release it

Many runtimes hold on to memory after load drops. A memory-based target then keeps the replica count high forever. Because the controller takes the highest desired count across metrics, one sticky memory figure overrides a calm CPU graph. Prefer CPU or a request-rate metric unless you have proof that memory tracks load.

5. Setting minReplicas to 1 for a critical service

One replica is a single point of failure during node drains and rollouts. Use at least two for anything user-facing, and add a PodDisruptionBudget so voluntary evictions never remove both at once.

6. Expecting instant relief from a spike

Metrics take time to scrape, pods take time to pull images and start, and nodes take longer still. Autoscaling absorbs ramps, not cliffs. For sudden launches, pre-scale by raising minReplicas an hour ahead, then lower it after the event.

7. Ignoring the v1 Metrics API note

Kubernetes 1.37 graduated metrics.k8s.io to a stable v1 API, per the August 27, 2026 release note. The HPA documentation also gained a description of a limitation involving v1.metrics.k8s.io. Read that section before you remove older Metrics API versions from your cluster, and keep metrics-server current.

Troubleshooting Kubernetes HPA and KEDA: 10 Fixes

Start every investigation with kubectl describe hpa NAME and read the conditions and events. Then match your symptom below.

#SymptomLikely causeFix
1TARGETS shows unknown forevermetrics-server missing or unhealthyRun kubectl top pods, fix metrics-server first
2Event: missing request for cpuContainer has no CPU requestAdd resources.requests.cpu to every container, including sidecars
3Metrics API not available on kindKubelet certificate not trustedAdd --kubelet-insecure-tls in the lab only
4Replicas bounce up and downWindow too short or two autoscalersRaise the scaleDown stabilization window, remove the duplicate HPA
5Replicas never shrinkAnother metric still above target, or window not elapsedCheck every metric in describe output and wait out the 5-minute default
6Stuck at maxReplicasLoad really exceeds capacity or request too lowRaise max, retune requests, check app latency
7Pods Pending after scale-upNo node capacityEnable a node autoscaler and check max node limits
8ScaledObject READY is FalseBad trigger address or authRun kubectl describe scaledobject, test the Redis address from a debug pod
9API rejects minReplicas: 0No object or external metric, or old clusterAdd such a metric, confirm Kubernetes 1.37 or use KEDA
10AKS pool will not scaleAzure Linux 2.0 node image removedMove the pool to a supported image such as AzureLinux3

The table gives the short version. These commands handle the three that need more than one line.

Debugging an unknown target

kubectl get apiservices | grep metrics
kubectl -n kube-system logs deploy/metrics-server --tail=30
kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes" | head -c 300

The first command should show the metrics API service as available. Logs full of x509 errors point to the kubelet certificate issue from the table. An empty raw query means metrics-server has not scraped yet or cannot reach the nodes.

Debugging a ScaledObject that never activates

kubectl describe scaledobject worker-scaler
kubectl -n keda logs deploy/keda-operator --tail=40
kubectl run redis-debug --rm -it --image=redis:7-alpine --restart=Never -- \
  redis-cli -h redis.default.svc.cluster.local LLEN jobs

If the debug pod returns a number and KEDA still reports no activity, compare the list name in the trigger with the one your producer uses. A typo in listName produces a healthy but permanently idle scaler, which looks like success until the queue grows.

Debugging pods that sit in Pending

kubectl get pods --field-selector=status.phase=Pending
kubectl describe pod POD_NAME | sed -n '/Events/,$p'
kubectl describe nodes | grep -A6 "Allocated resources"

The event message names the blocker, such as insufficient CPU or an unsatisfied node selector. If the message says no node fits and your autoscaler shows no action, check its own logs and its maximum node setting. The autoscaler cannot exceed a cap you set months ago and forgot.

Advanced Kubernetes HPA Tips for Production

Once the basics hold, these upgrades pay off. Each one answers a failure mode that shows up only under real traffic.

Scale on request rate or latency, not just CPU

CPU tracks work only for compute-bound services. A service that waits on a database can sit at low CPU while users suffer. Feed the HPA a custom or external metric such as requests per second per pod through a Prometheus adapter or a KEDA Prometheus trigger. Google said on October 20, 2026 that GKE HPA now supports custom PromQL metrics, which cuts the glue code on that platform. Elsewhere, you still need an adapter in the middle.

Combine triggers in one ScaledObject

A worker can scale on queue depth and CPU together. KEDA converts every trigger into a metric, and the underlying HPA picks the highest replica count. Add a second trigger like this:

  triggers:
    - type: redis
      metadata:
        address: redis.default.svc.cluster.local:6379
        listName: jobs
        listLength: "5"
    - type: cpu
      metricType: Utilization
      metadata:
        value: "70"

Note that CPU and memory triggers cannot take a workload to zero, for the same reason as with the native HPA. Keep a queue or cron trigger in the mix if you want zero.

Add a cron floor for known peaks

If traffic climbs every weekday at the same hour, do not wait for CPU to notice. A KEDA cron trigger raises the minimum before the rush and drops it afterward. Pair it with the queue trigger, and the worker stays warm during business hours but still reaches zero overnight.

Protect scale-down with a PodDisruptionBudget and graceful shutdown

Scale-down terminates pods, and a pod that dies mid-request creates user-visible errors. Set terminationGracePeriodSeconds to cover your longest request, handle SIGTERM by finishing in-flight work and add a preStop sleep so load balancers drain first. A PodDisruptionBudget guards against too many simultaneous evictions during node consolidation.

Watch the autoscaler itself

Alert on three signals: HPA at maxReplicas for more than a set time, pods pending beyond a few minutes and KEDA scaler errors. The first tells you the ceiling is too low, the second says the node layer is stuck and the third means a trigger is silently failing. Tie cost reporting to the same labels so finance sees which team’s autoscaler spent what. Cost labels are the first step toward a proper FinOps practice around your clusters.

The Complete Working Project: One Script to Rebuild the Lab

Put the five YAML files from the steps in one folder with these names: kind-hpa-lab.yaml, app.yaml, hpa-v2.yaml (the tuned version from Step 8), queue.yaml and scaledobject.yaml. Then save the following as run-lab.sh, make it executable and run it. It builds the cluster, installs metrics-server and KEDA, applies every manifest and waits until each piece is ready.

#!/usr/bin/env bash
set -euo pipefail

CLUSTER=hpa-lab

echo "==> Creating kind cluster"
kind create cluster --name "$CLUSTER" --config kind-hpa-lab.yaml

echo "==> Installing metrics-server"
helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/ >/dev/null
helm repo add kedacore https://kedacore.github.io/charts >/dev/null
helm repo update >/dev/null
helm upgrade --install metrics-server metrics-server/metrics-server \
  --namespace kube-system --set args={--kubelet-insecure-tls}
kubectl -n kube-system rollout status deployment/metrics-server --timeout=180s

echo "==> Deploying app and HPA"
kubectl apply -f app.yaml
kubectl rollout status deployment/php-apache --timeout=180s
kubectl apply -f hpa-v2.yaml

echo "==> Installing KEDA"
helm upgrade --install keda kedacore/keda --namespace keda --create-namespace
kubectl -n keda rollout status deployment/keda-operator --timeout=180s
kubectl -n keda rollout status deployment/keda-operator-metrics-apiserver --timeout=180s
kubectl -n keda rollout status deployment/keda-admission-webhooks --timeout=180s

echo "==> Deploying queue, worker and ScaledObject"
kubectl apply -f queue.yaml
kubectl rollout status deployment/redis --timeout=180s
kubectl apply -f scaledobject.yaml

echo "==> Lab ready"
kubectl get hpa
kubectl get scaledobject
echo "Next: run the load generator from Step 6 and the job push from Step 11."
chmod +x run-lab.sh
./run-lab.sh

Expected tail of the output, after a few minutes:

==> Lab ready
NAME         REFERENCE               TARGETS       MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: 1%/50%   2         10        2          95s
NAME            SCALETARGETKIND      SCALETARGETNAME   MIN   MAX   READY   ACTIVE   AGE
worker-scaler   apps/v1.Deployment   worker            0     10    True    False    12s

From here, extend the project: swap the sample image for your own service, point the ScaledObject at a managed queue and move the manifests into your GitOps repository with the replicas field removed.

Kubernetes HPA FAQ

What is Kubernetes HPA?

The Horizontal Pod Autoscaler is a built-in Kubernetes controller that raises or lowers the replica count of a workload based on observed metrics such as CPU. It is open source and has no separate price.

How long does the HPA take to scale down?

By default the controller applies a 5-minute downscale stabilization window, so replicas drop only after the metrics have stayed low for that period. You can change the window per HPA through the behavior.scaleDown field.

Can Kubernetes HPA scale to zero?

Yes, with conditions. In Kubernetes 1.37 the scale-to-zero feature is beta and enabled by default, and it works with object or external metrics when minReplicas is 0. CPU and memory targets cannot reach zero. KEDA offers the same outcome on older clusters.

Should I use HPA, VPA or KEDA?

Use the HPA for stateless services, KEDA for event-driven workers and queues, and VPA in recommendation mode to right-size requests. Avoid running HPA and VPA on the same CPU metric for one workload.

Why does my HPA show unknown for CPU?

Either metrics-server is not working, or a container lacks a CPU request. Check kubectl top pods first, then confirm every container in the pod declares a CPU request.

Does the HPA add nodes to my cluster?

No. The HPA only changes replica counts. A separate node autoscaler such as the cluster autoscaler, Karpenter, EKS Auto Mode or GKE Autopilot adds machines when new pods cannot be scheduled.

Can I use KEDA and a normal HPA together?

Not on the same Deployment. KEDA manages its own HPA for each ScaledObject, so a second HPA creates conflicting commands. Put every trigger, including CPU, inside the ScaledObject.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles