NewCompare CPU & GPU pricing across AWS, Azure & GCP
Workload OperatorWorkload Optimization

Migrating to DevZero's KEDA

Move your existing KEDA ScaledObjects onto DevZero's KEDA and retire your own KEDA operator, ending with a single KEDA driving all horizontal autoscaling.

Migrating from your KEDA to DevZero's KEDA

DevZero can run KEDA for you. This guide moves your existing KEDA ScaledObjects onto DevZero's KEDA and retires your own KEDA operator, so you end up with a single KEDA — DevZero's — driving all your autoscaling.

Every step below has a short, copy‑paste block that does the work for all of your objects at once (no "repeat for each object" by hand). Read the paragraph, run the block, check the output it prints, move on.

How the handover works (read this once)

DevZero does not take over your ScaledObject in place. For each workload you migrate, DevZero's operator creates its own object — a ScaledObject named mpa-<kind>-<target>, owned by a DevZero WorkloadRule. Your original object and DevZero's mpa-* object are two different resources.

That matters because any KEDA operator in the cluster reconciles every ScaledObject, no matter who owns it. If your original object and DevZero's mpa-* object both exist while a KEDA operator is running, that operator builds two HPAs for one Deployment and they fight each other. To avoid that, this guide does the swap while no KEDA operator is running: you remove your originals and stop your KEDA first, then bring DevZero's KEDA up as the only one.

The one trade‑off — a short, safe pause. Between stopping your KEDA (Step 4) and DevZero's KEDA serving (Step 8), external‑metric autoscaling does not update. Your Deployments simply hold their current replica count — nothing restarts, nothing scales to zero. Plan for a few minutes of "frozen" replicas; that's the entire blast radius.

Your objects are copied verbatim. keda-import performs a blind copy: the entire ScaledObject spec — every trigger, advanced block (including scalingModifiers and a custom HPA name), fallback, and any field a future KEDA release adds — plus all of your labels and annotations (including autoscaling.keda.sh/paused) are carried onto DevZero's mpa-* object unchanged. The only things intentionally dropped are the tool‑ownership keys that would make Argo CD / Flux / Helm reclaim our object (kubectl last‑applied, argocd.argoproj.io/*, *.toolkit.fluxcd.io/*, meta.helm.sh/*). A paused workload stays paused; there is nothing special to do for any field.

If a GitOps controller manages your ScaledObjects

Read this before Step 3. If your ScaledObjects are applied by Argo CD or Flux (i.e. they live in a Git repo, not just in the cluster), a plain kubectl delete in Step 3 will not stick: the GitOps controller sees the deletion as drift and re‑creates the object from Git within minutes. You would then have your original and DevZero's mpa-* object for the same Deployment — two KEDA‑built HPAs fighting, the exact collision this guide exists to avoid.

Check whether this applies to you:

# Argo CD stamps a tracking id; Flux stamps kustomize/helm labels.
kubectl get scaledobjects.keda.sh -A -o json | jq -r '
  .items[] | select(
    (.metadata.annotations["argocd.argoproj.io/tracking-id"] != null) or
    (.metadata.labels["kustomize.toolkit.fluxcd.io/name"] != null) or
    (.metadata.labels["helm.toolkit.fluxcd.io/name"] != null)
  ) | "\(.metadata.namespace)/\(.metadata.name)"'

If that prints nothing, you are not affected — skip to Prerequisites. If it lists objects, pick one of these before you reach Step 3:

  • Recommended — migrate the source of truth. Commit the wr-*.yaml WorkloadRules from Step 2 into the same Git path that holds those ScaledObjects, and remove the ScaledObject manifests from Git in the same change. Let the GitOps controller apply the WorkloadRules and prune the ScaledObjects. Because Git no longer contains the originals, there is nothing to self‑heal, and you skip the kubectl delete in Step 3 entirely (the prune does it for you). This is the durable outcome — your Git repo now describes the DevZero rules.
  • Quicker, temporary — suspend reconciliation for the migration window. Turn off auto‑sync and self‑heal on the managing Application(s) so your manual kubectl delete/apply is not reverted, then re‑point Git and re‑enable afterwards. For Argo CD:
    # Disable automated sync (which includes selfHeal) on the app that owns them.
    argocd app set <app> --sync-policy none
    # ... run Steps 3–8 by hand ...
    # Then update Git to the WorkloadRules and re-enable:
    argocd app set <app> --sync-policy automated --self-heal
    Leaving self‑heal on during the window will undo Step 3 — do not skip this.

Either way, keep DevZero's KEDA off (dzkeda.enabled=false) until Step 8, so nothing reconciles a re‑created original while you work.

Prerequisites

  • DevZero is already installed with dzkeda.enabled=false. In this mode DevZero ships no KEDA at all — no controller and no CRDs — so it cannot collide with your running KEDA. (This is the default; you turn DevZero's KEDA on in Step 8.)
  • kubectl and helm are configured against the target cluster.
  • The keda-import tool is on your PATH. It converts a ScaledObject into a DevZero WorkloadRule. Contact DevZero support for the prebuilt binary for your OS/architecture — no Go toolchain or source checkout required.

Set these once; every block below reuses them:

export DZ_RELEASE=dakr                 # your DevZero helm release name  (helm list -A)
export DZ_NS=devzero-system            # namespace DevZero is installed in
export DZ_CHART=oci://registry-1.docker.io/devzeroinc/dakr-operator   # or a local ./path
export KEDA_RELEASE=keda               # YOUR existing KEDA helm release name
export KEDA_NS=keda                    # namespace YOUR KEDA is installed in
mkdir -p keda-migration && cd keda-migration     # all backups land here

Migration

Back up everything (this is your rollback)

Before changing anything, snapshot the current world: your ScaledObjects, your TriggerAuthentications, and the Secrets those auths point at. DevZero's KEDA reads the very same TriggerAuthentications and Secrets after cutover, so we preserve them exactly. We also record current replica counts so you can confirm parity at the end. Everything is written into the keda-migration/ folder.

kubectl get scaledobjects.keda.sh -A -o yaml                 > backup-scaledobjects.yaml
kubectl get triggerauthentications.keda.sh,clustertriggerauthentications.keda.sh -A -o yaml \
                                                             > backup-triggerauth.yaml
kubectl get deploy -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,REPLICAS:.spec.replicas \
                                                             > baseline-replicas.txt
echo "backed up $(kubectl get scaledobjects.keda.sh -A --no-headers | wc -l) ScaledObject(s)"

The Secrets referenced by your TriggerAuthentications live in your app namespaces and are not owned by your KEDA release, so uninstalling KEDA in Step 4 does not touch them. They stay put; DevZero's KEDA reads them as‑is.

Convert every ScaledObject into a WorkloadRule

keda-import reads a ScaledObject and prints the equivalent DevZero WorkloadRule, copying the object verbatim (all triggers, spec fields, labels, and annotations — see the blind‑copy note above). This block runs it for every ScaledObject in the cluster and drops one wr-*.yaml per object into keda-migration/. It only writes files — nothing is applied to the cluster yet, so it is completely safe to run and re‑run.

: > imported.txt   # records the exact originals we convert, for Step 3
kubectl get scaledobjects.keda.sh -A \
  -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name --no-headers |
while read -r ns name; do
  if kubectl get scaledobject "$name" -n "$ns" -o yaml | keda-import > "wr-$ns-$name.yaml" 2>/dev/null; then
    echo "$ns $name" >> imported.txt
    echo "converted  $ns/$name  -> wr-$ns-$name.yaml"
  else
    echo "SKIPPED    $ns/$name  (unsupported field, e.g. scalingModifiers or a custom HPA name)"
    rm -f "wr-$ns-$name.yaml"
  fi
done

Because the conversion is a blind copy, a SKIP is now rare — it means the object is structurally broken (e.g. no scaleTargetRef.name), not that a field is unsupported. If anything is SKIPPED, leave its original in place — do not delete it in Step 3 — and inspect it by hand.

Delete your originals (while your KEDA is still running)

Deleting a ScaledObject requires your KEDA operator to be alive: KEDA runs a finalizer that also removes the HPA it built. If you stopped KEDA first, the object would wedge in Terminating forever. So we delete the originals now, while your KEDA is still up. Deleting a ScaledObject does not touch the Deployment — it simply stops being autoscaled and holds its current replica count.

This block deletes exactly the originals you recorded in imported.txt in Step 2, so anything you SKIPPED is left alone automatically.

GitOps users: if you took the recommended path in the GitOps section above (migrated the source of truth to WorkloadRules in Git), skip this block — your controller already pruned the originals. Run it only if you chose the temporary path, and only after auto‑sync and self‑heal are off, or the objects will be re‑created from Git before you finish.

while read -r ns name; do
  kubectl delete scaledobject "$name" -n "$ns" --wait=true --timeout=60s
done < imported.txt
kubectl get scaledobjects.keda.sh -A     # only SKIPPED originals (if any) should remain

Stop your KEDA

Scaling your KEDA operator to zero is not enough: its cluster‑scoped pieces (the keda-admission webhook, the external.metrics APIService, its ClusterRoles) would linger and collide with DevZero's KEDA. Uninstall the release so those are removed cleanly.

helm uninstall "$KEDA_RELEASE" -n "$KEDA_NS"
kubectl get deploy -n "$KEDA_NS" | grep -i keda || echo "your KEDA controller is gone"

Don't worry about the KEDA CRDs here. Whether your uninstall keeps them or removes them, the next two steps make DevZero the sole, clean owner — and your TriggerAuthentications are restored from backup in Step 6 regardless. This is the "frozen replicas" window; it ends in Step 8.

Hand the KEDA CRDs to DevZero

If your uninstall left the KEDA CRDs behind, they are still tagged as owned by your old release, and DevZero's Helm release would refuse to manage them. This one block re‑stamps them as DevZero‑owned so DevZero adopts them in Step 8. It is safe to run even if the CRDs are already gone (the commands simply no‑op on what isn't there).

for crd in scaledobjects.keda.sh scaledjobs.keda.sh triggerauthentications.keda.sh \
           clustertriggerauthentications.keda.sh cloudeventsources.eventing.keda.sh \
           clustercloudeventsources.eventing.keda.sh; do
  kubectl annotate crd "$crd" \
    meta.helm.sh/release-name="$DZ_RELEASE" meta.helm.sh/release-namespace="$DZ_NS" \
    --overwrite 2>/dev/null
  kubectl label crd "$crd" app.kubernetes.io/managed-by=Helm --overwrite 2>/dev/null
done
echo "CRD ownership handed to $DZ_RELEASE (where the CRDs still exist)"

Restore your TriggerAuthentications

DevZero's KEDA authenticates to your scalers using your existing TriggerAuthentications and their Secrets. If Step 4 removed the KEDA CRDs, it also removed the TriggerAuthentication objects (the Secrets always survive). Re‑applying your Step 1 backup guarantees they're present no matter what your uninstall did — and if they were never removed, re‑applying is a harmless no‑op.

Apply the backup only when it actually contains TriggerAuthentications. Guarding on the content this way keeps a real apply failure (a missing CRD, an RBAC gap, a corrupt backup) from being hidden — the naive 2>/dev/null || echo form swallowed every error and reported "nothing to do" even when the restore failed, which would leave DevZero's KEDA unable to authenticate after cutover.

if grep -qE 'kind: (Cluster)?TriggerAuthentication' backup-triggerauth.yaml; then
  kubectl apply -f backup-triggerauth.yaml     # errors surface — do not hide them
else
  echo "no TriggerAuthentications to restore (none existed) — nothing to do"
fi

Create the DevZero WorkloadRules

Now apply every WorkloadRule you generated in Step 2. No KEDA operator is running yet, so DevZero simply records each rule and (once its KEDA is on in Step 8) will render an mpa-* ScaledObject for it. Applying them now means the moment DevZero's KEDA starts, everything reconciles at once.

if ls wr-*.yaml >/dev/null 2>&1; then
  kubectl apply -f wr-*.yaml
else
  echo "no wr-*.yaml to apply (nothing converted in Step 2, or you migrated via Git)"
fi
kubectl get workloadrules.dakr.devzero.io -A     # one per converted object

Turn on DevZero's KEDA

Flip DevZero's KEDA on with a single flag. This deploys DevZero's KEDA controller, adopts the CRDs you re‑stamped in Step 5 (or creates them fresh if your uninstall removed them — either way, no conflict), and reconciles every WorkloadRule into its mpa-* ScaledObject. --reuse-values keeps every other setting from your original DevZero install exactly as it was.

helm upgrade "$DZ_RELEASE" "$DZ_CHART" -n "$DZ_NS" --reuse-values \
  --set dzkeda.enabled=true --wait --timeout 8m

If you originally installed DevZero from a values file, use -f your-values.yaml in place of --reuse-values so the flip renders from the same source of truth.

Verify the cutover

DevZero's KEDA should now be the only KEDA: its adapter registers the external metrics API, and each mpa-* ScaledObject becomes READY=True with one HPA per target. This block prints the three things that prove it, plus a replica comparison against your Step 1 baseline.

kubectl get apiservice v1beta1.external.metrics.k8s.io \
  -o custom-columns=NAME:.metadata.name,AVAILABLE:'.status.conditions[?(@.type=="Available")].status'
kubectl get scaledobjects.keda.sh -A \
  -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,READY:'.status.conditions[?(@.type=="Ready")].status',OWNER:.metadata.ownerReferences[0].kind
kubectl get hpa -A | grep keda-hpa-mpa-        # one per migrated workload
diff <(sort baseline-replicas.txt) \
     <(kubectl get deploy -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,REPLICAS:.spec.replicas | sort) \
  && echo "replica counts match the pre-migration baseline"

Expect AVAILABLE=True, every mpa-* object READY=True and OWNER=WorkloadRule, and one keda-hpa-mpa-* HPA per workload. If AVAILABLE is stuck, check DevZero's metrics adapter: kubectl logs -n "$DZ_NS" -l app=keda-operator-metrics-apiserver.

You're done. Never delete an mpa-* ScaledObject directly — remove one only by deleting its WorkloadRule (the owner reference cascades the cleanup).

Rollback (safe any time before you discard your backups)

If anything looks wrong, turn DevZero's KEDA back off and restore your originals. Because your ScaledObject and TriggerAuthentication backups are complete, this returns you to exactly where you started.

helm upgrade "$DZ_RELEASE" "$DZ_CHART" -n "$DZ_NS" --reuse-values --set dzkeda.enabled=false --wait
kubectl delete workloadrules.dakr.devzero.io --all -A       # cascade-removes the mpa-* objects
kubectl apply -f backup-triggerauth.yaml                    # restore your auths
kubectl apply -f backup-scaledobjects.yaml                  # restore your originals
helm repo add kedacore https://kedacore.github.io/charts >/dev/null 2>&1 || true
helm repo update kedacore >/dev/null                        # ensure the chart is fetchable
helm install "$KEDA_RELEASE" kedacore/keda -n "$KEDA_NS" --create-namespace   # reinstall your KEDA

Your KEDA comes back up and resumes reconciling your restored ScaledObjects.

On this page