HPA Is Great, Until It Isn’t
You’ve got Kubernetes running. Maybe k3s on a couple of mini PCs. You’ve wired up the Horizontal Pod Autoscaler and watched it scale your app from 2 pods to 6 when CPU hits 70%. Nice.
Then reality shows up.
Your video transcoding job needs to kick off at 3 AM. Your image-processing worker should spin up when Frigate catches motion on the security cam. Your queue worker should scale based on how many rows are sitting in a Postgres table, not whether the CPU is sweating.
HPA out of the box only understands CPU and memory. That’s like hiring a bouncer who only checks height, it works until the situation requires actual judgment.
That’s where KEDA comes in.
What KEDA Actually Is
KEDA (Kubernetes Event-Driven Autoscaling) is a CNCF graduated project that extends HPA with a massive catalog of event sources, called scalers. Instead of asking “how hot is the CPU?”, KEDA asks “how many messages are in the queue?” or “is it 3 AM on a Tuesday?”
The architecture is straightforward:
- KEDA Operator: watches for
ScaledObjectandScaledJobCRDs you create - KEDA Metrics Adapter: exposes external metrics to HPA so Kubernetes’ native scaling machinery can do its thing
- Scalers: the connectors to your event sources (RabbitMQ, Redis, Postgres, MQTT, Cron, Prometheus, etc.)
You define a ScaledObject that points at your Deployment and a trigger (or multiple triggers). KEDA handles the rest, including the magic trick: scale to zero and back.
HPA can’t scale below 1 replica. KEDA owns the 0→1 transition itself, then hands off to HPA for 1→N. For a home lab where you don’t want idle workers burning RAM at 2 PM, this is huge.
Installing KEDA
Helm is the standard path. Add the repo and install into its own namespace:
helm repo add kedacore https://kedacore.github.io/chartshelm repo update
helm install keda kedacore/keda \ --namespace keda \ --create-namespace \ --version 2.20.1Verify it’s up:
kubectl get pods -n kedaYou should see three pods: keda-operator, keda-operator-metrics-apiserver, and keda-admission-webhooks. All three need to be Running before you start creating ScaledObjects.
That’s honestly it for the install. KEDA is one of those tools that gets out of your way quickly.
The ScaledObject CRD
Every KEDA setup revolves around a ScaledObject. Here’s the anatomy:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: my-worker namespace: defaultspec: scaleTargetRef: name: my-worker-deployment # the Deployment to scale minReplicaCount: 0 # scale to zero — saves resources maxReplicaCount: 10 cooldownPeriod: 300 # seconds before scaling back to 0 (default 300) pollingInterval: 30 # how often KEDA checks the scaler (seconds) triggers: - type: rabbitmq # the scaler metadata: protocol: amqp queueName: jobs queueLength: "5" # one pod per 5 messages in queueThe cooldownPeriod is your friend. It prevents flapping, if your queue drains and refills every 45 seconds, you don’t want pods appearing and vanishing like ghosts. 300 seconds (5 minutes) is a reasonable default; tune it down if your workloads start and stop cleanly in seconds.
Home Lab Use Cases (With Actual YAML)
Cron Trigger: Batch Transcoding at 3 AM
You’ve got Jellyfin, a pile of rips, and a transcoding worker that you absolutely do not want running during the day consuming RAM. Cron trigger:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: transcode-batch namespace: mediaspec: scaleTargetRef: name: transcode-worker minReplicaCount: 0 maxReplicaCount: 4 cooldownPeriod: 600 triggers: - type: cron metadata: timezone: "America/New_York" start: "0 3 * * *" # 3 AM end: "0 5 * * *" # 5 AM desiredReplicas: "4"Between 3 to 5 AM, four workers spin up and chew through the queue. At 5 AM, they scale back to zero. Your home lab CPU is free the rest of the day. Your 2 AM self doesn’t have to remember to run anything.
MQTT Trigger: Frigate Motion Events
This one’s genuinely cool for home automation nerds. You’ve got Frigate detecting motion, publishing to an MQTT broker (Mosquitto). A computer vision worker should spin up only when there’s something to process.
First, store the MQTT connection as a secret:
apiVersion: v1kind: Secretmetadata: name: mqtt-secrets namespace: visiontype: OpaquestringData: mqttHost: "mqtt://mosquitto.home.svc.cluster.local:1883"Then the ScaledObject:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: frigate-processor namespace: visionspec: scaleTargetRef: name: motion-worker minReplicaCount: 0 maxReplicaCount: 3 cooldownPeriod: 120 triggers: - type: mqtt metadata: brokerList: "mosquitto.home.svc.cluster.local:1883" topic: "frigate/events" qosLevel: "1" messageCount: "1" authenticationRef: name: mqtt-trigger-authOne message in the topic, one pod spins up. Camera quiet for two minutes, pod goes away. For a home lab this is the kind of thing that makes you feel like you’ve actually built something clever.
Postgres Trigger: Queue Table Worker
You’ve got a jobs table. Workers poll it. You want more workers when the backlog grows. No Kafka, no Redis, just Postgres rows.
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: job-queue-worker namespace: appspec: scaleTargetRef: name: job-processor minReplicaCount: 0 maxReplicaCount: 8 triggers: - type: postgresql metadata: query: "SELECT COUNT(*) FROM jobs WHERE status = 'pending'" targetQueryValue: "10" # one pod per 10 pending rows dbName: "appdb" sslmode: "disable" host: "postgres.app.svc.cluster.local" port: "5432" userName: "worker_user" passwordFromEnv: DB_PASSWORDWhen pending jobs exceed 100 rows, you’ll have 10 workers. Queue drains to zero? Workers go away. It’s the most boring architecture possible, and it works perfectly.
Redis Streams Trigger: Bull/RQ Workers
If you’re running Node.js Bull queues or Python RQ backed by Redis, KEDA speaks Redis Streams natively:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: rq-worker-scaler namespace: workersspec: scaleTargetRef: name: rq-worker minReplicaCount: 0 maxReplicaCount: 6 cooldownPeriod: 180 triggers: - type: redis-streams metadata: address: "redis.workers.svc.cluster.local:6379" stream: "job-stream" consumerGroup: "rq-workers" pendingEntriesCount: "5"pendingEntriesCount: "5" means one pod per 5 messages that have been delivered to the consumer group but not acknowledged yet. Real backpressure, not fake CPU-based guessing.
ScaledJob: For Fan-Out Batch Work
ScaledObject scales a long-running Deployment. ScaledJob is for workloads that should run once per event, think: one Kubernetes Job per message.
apiVersion: keda.sh/v1alpha1kind: ScaledJobmetadata: name: photo-resize-job namespace: mediaspec: jobTargetRef: template: spec: containers: - name: resize image: myrepo/photo-resizer:latest restartPolicy: Never minReplicaCount: 0 maxReplicaCount: 20 triggers: - type: rabbitmq metadata: queueName: photo-uploads host: amqp://rabbitmq.default.svc.cluster.local:5672/ queueLength: "1"Each photo upload message in RabbitMQ spawns one Job pod. Pod runs, resizes, exits. No persistent worker sleeping between requests. For batch workloads where the work unit is discrete and you want parallelism, ScaledJob is the right model.
The Scale-to-Zero Catch: Cold Start Latency
Here’s the honest part nobody leads with.
Scale to zero sounds amazing. It is amazing. Until a user hits your service and waits 15 seconds while a container starts up.
Typical cold start times:
- Go / Rust / Node.js: 5 to 15 seconds (image pull + init)
- Python: 10 to 25 seconds
- JVM (Java, Kotlin, Scala): 30 to 90 seconds depending on heap and framework
- .NET: 20 to 60 seconds
For background workers processing queues, this is fine. Nobody’s watching the pod start. For HTTP request handlers, it’s user-facing latency.
Two mitigation options:
1. KEDA HTTP Add-on, intercepts HTTP requests during cold start and holds them in a buffer while the pod initializes. The caller’s connection stays open; the request isn’t lost. Install it separately:
helm install http-add-on kedacore/keda-add-ons-http \ --namespace kedaThen use HTTPScaledObject instead of ScaledObject for HTTP-triggered services.
2. Pod Pre-warming / minReplicaCount: 1, honestly, for latency-sensitive services in a home lab, just set minReplicaCount: 1. You keep one pod warm at all times, skip cold starts entirely, and still let KEDA scale out to N under load. You’re not running a serverless platform that bills per millisecond.
Scale to zero is most valuable for batch workers, scheduled jobs, and event-driven processors where nobody’s watching the clock.
Prometheus Trigger: Scaling on Anything
If you’ve got Prometheus running (and if you’re reading this article, you probably do or should), you can scale on any metric that Prometheus scrapes. Request rate, error rate, custom business metrics, whatever.
triggers: - type: prometheus metadata: serverAddress: http://prometheus.monitoring.svc.cluster.local:9090 metricName: http_requests_in_flight query: sum(rate(http_requests_total{job="api"}[2m])) threshold: "100" # one pod per 100 req/sThis is the power move. You’ve got full observability from Prometheus and you’re using it as the autoscaling brain. If your SLO dashboard is already on a Grafana wall, your scaling policy lives in the same place your alerts do.
A Note on KNative
KEDA and KNative Serving can coexist, and for pure “scale from zero on HTTP” use cases, KNative is often cleaner. KNative Serving handles cold starts, request buffering, and traffic splitting out of the box.
But KNative brings significant operational overhead, it’s a platform on top of a platform. For a home lab running k3s on commodity hardware, KEDA + the HTTP add-on covers 95% of the same ground without pulling in the KNative stack. Unless you’re already invested in KNative, start with KEDA.
Should You Bother?
If you’re running k3s on a box with 16 to 32 GB RAM and you’ve got background workers, cron jobs, or event-driven workloads: yes, absolutely bother.
The install is 10 minutes. The first ScaledObject YAML takes another 15. And then you get workers that actually sleep when there’s nothing to do, spin up when there is, and don’t require you to remember to run a cron job manually or size your Deployment based on peak-load guesses.
The scaler catalog in 2026 is deep: RabbitMQ, Kafka, Redis, Redis Streams, Postgres, MySQL, MQTT, Prometheus, Cron, NATS, AWS SQS, GCP PubSub, Azure Service Bus, GitHub Runners, and more. Odds are your event source is already supported.
Where it doesn’t make sense: simple stateless HTTP services with consistent traffic patterns. HPA on CPU/memory is genuinely fine for those, and adding KEDA is complexity without payoff.
For everything else, the batch jobs, the queue workers, the home automation triggers, the scheduled tasks, KEDA is one of those tools that makes you wonder how you ran Kubernetes without it.
Scale to zero. Wake on demand. Go touch grass.