A hands-on demo of Worker Versioning + the Temporal Worker Controller on Kubernetes. A small FastAPI service starts test workflows and reads worker-deployment status from the cluster. A React UI runs four scenarios and shows the rollout state in real time.
You'll see how four common workflow shapes behave during a rolling upgrade from worker version A to version B:
- A — a long pinned workflow stays on its starting version
- B — an auto-upgrade workflow can finish on a newer version
- C — a workflow type missing on B fails until you roll back to A
- D — a pinned workflow that uses
continue_as_newto hand off to a newer version
| Tool | Purpose |
|---|---|
| Docker (Rancher Desktop recommended) | Build worker-controller-demo:v-a / :v-b images |
| Kubernetes (Rancher Desktop, kind, k3d, etc.) | Runs the controller + worker pods |
kubectl |
Apply manifests, watch state |
| Helm 3 | Install the worker controller |
| Temporal Cloud account (or self-hosted ≥ 1.29.1) | Worker Versioning enabled |
uv (Python) and Node.js + npm |
Run the demo API and UI on your laptop |
# Worker controller CRDs + chart (pick a release from
# https://github.com/temporalio/temporal-worker-controller/releases)
helm install temporal-worker-controller-crds \
oci://docker.io/temporalio/temporal-worker-controller-crds \
--version <VERSION> --namespace temporal-system --create-namespace
helm install temporal-worker-controller \
oci://docker.io/temporalio/temporal-worker-controller \
--version <VERSION> --namespace temporal-system
# Sanity check — both controller pods should be Running
kubectl get pods -n temporal-systemcert-manager? Not needed for this demo. The controller's optional admission webhook (used by
WorkerResourceTemplate) is the only thing that wants TLS, and we don't use it. If a future upgrade prompts a webhook error, install cert-manager then.
In Temporal Cloud, create an API key (UI → API Keys → Create). Then:
kubectl create namespace worker-controller-demo
kubectl create secret generic temporal-api-key -n worker-controller-demo \
--from-literal=api-key='YOUR_API_KEY'cp k8s/temporal-connection.example.yaml k8s/temporal-connection.yaml
# Edit spec.hostPort to your regional gRPC host (e.g. us-east-1.aws.api.temporal.io:7233)
kubectl apply -f k8s/temporal-connection.yaml# v-a: registers all workflow types
docker build -t worker-controller-demo:v-a --build-arg DEMO_WORKER_VERSION=a .
# v-b: omits ONLY RollbackWorkflow (Scenario C will fail on v-b until rollback).
# RolloutGate stays registered so the controller's rollout gate succeeds and the ramp completes.
docker build -t worker-controller-demo:v-b --build-arg DEMO_WORKER_VERSION=b \
--build-arg DEMO_OMIT_ROLLBACK=1 .cp k8s/temporal-worker-deployment.example.yaml k8s/temporal-worker-deployment.yaml
# Edit spec.workerOptions.temporalNamespace to your Temporal Cloud namespace
kubectl apply -f k8s/temporal-worker-deployment.yaml
# Watch until CURRENT and TARGET are both v-a-<hash> and RolloutComplete
kubectl get twd -n worker-controller-demo -wcp .env.example .env| Variable | Value |
|---|---|
TEMPORAL_ADDRESS |
Your regional gRPC host (matches the TemporalConnection) |
TEMPORAL_NAMESPACE |
Same as spec.workerOptions.temporalNamespace in the TWD |
TEMPORAL_API_KEY |
The API key value you put in the K8s Secret |
TEMPORAL_TASK_QUEUE |
worker-controller-demo (matches the TWD) |
K8S_NAMESPACE |
worker-controller-demo |
K8S_TWD_NAME |
worker-controller-demo |
TEMPORAL_DEPLOYMENT_NAME |
worker-controller-demo/worker-controller-demo (set this — controller prefixes the K8s namespace; needed for pin-to-current in Scenario A) |
Keep .env out of git (it's already in .gitignore).
uv sync
uv run demo-api # terminal 1
cd web && npm install
npm run dev # terminal 2Open http://localhost:5173. The top panel shows live TemporalWorkerDeployment status. Four cards below run the scenarios.
kubectl get twd -n worker-controller-demo should show CURRENT = TARGET = v-a-<hash>, RolloutComplete.
In the UI, click Run scenario A, then Run scenario B. Both kick off on v-a:
- A (
pinned-demo-…): pinned to v-a; probes, sleeps ~90s, probes again. - B (
auto-demo-…): auto-upgrade; probes (ok-a), sleeps 150s, probes again.
Keep them running. They give the rollout something interesting to do.
# Edit k8s/temporal-worker-deployment.yaml:
# image: worker-controller-demo:v-b
# DEMO_WORKER_VERSION: "b"
kubectl apply -f k8s/temporal-worker-deployment.yaml
kubectl get twd -n worker-controller-demo -wThe controller starts a RolloutGate workflow on v-b → it succeeds (v-b registers RolloutGate) → ramp progresses 25% → 50% → 75% → Current. While that's happening:
- A stays pinned to v-a and completes there (pinned workflows never move).
- B finishes its second probe on whichever build is Current at that moment. If the ramp finishes before B wakes up, result is
ok-a -> ok-b; otherwiseok-a -> ok-a.
Once CURRENT = v-b-<hash>, click Run scenario C, then Run scenario D:
- C (
rollback-demo-…): auto-upgrade; dispatched to v-b. v-b doesn't registerRollbackWorkflow→ workflow task fails repeatedly withclass not registered. The workflow staysRunning(no timeout) and keeps retrying. - D (
can-demo-…): pinned to v-b; probes (ok-b), sleeps 2 minutes 30 seconds, then callscontinue_as_newwithinitial_versioning_behavior=AUTO_UPGRADE.
# Edit k8s/temporal-worker-deployment.yaml:
# image: worker-controller-demo:v-a
# DEMO_WORKER_VERSION: "a"
kubectl apply -f k8s/temporal-worker-deployment.yamlThe controller ramps back to v-a as Current.
- C recovers: its next workflow-task retry is auto-upgraded to Current = v-a → v-a registers
RollbackWorkflow→ runs the activity → returnsok-a→Completed. The workflow was never lost. - D hands off: when its 2:30 timer fires, gen 0 closes on v-b (
ContinuedAsNew); gen 1 starts withAUTO_UPGRADEand lands on Current = v-a → probes → returnsgen=1 probe=ok-a→Completed. A pinned workflow safely moved from v-b to v-a at the CaN boundary.
You've now seen all four behaviors in one continuous flow: pinned (A) stays put, auto-upgrade (B) moves, a missing workflow type on v-b (C) blocks until rollback then recovers, and continue-as-new with AUTO_UPGRADE (D) hands off to whichever build is Current at the boundary.
If a ramp halts or you want a clean baseline:
kubectl delete twd worker-controller-demo -n worker-controller-demo
# Wait for pods to drain
kubectl get pods -n worker-controller-demo
# Flip k8s/temporal-worker-deployment.yaml back to image: worker-controller-demo:v-a
kubectl apply -f k8s/temporal-worker-deployment.yamlDrained worker-deployment versions on the Temporal side are harmless leftover bookkeeping; they don't block a fresh apply unless you reuse the exact same pod template hash. Bumping the image tag (e.g. :v-b1) is the simplest way to force a brand-new build id.
- The UI shows "pin skipped" for Scenario A or C. Set
TEMPORAL_DEPLOYMENT_NAME=<k8s-namespace>/<twd-name>in.env(the controller prefixes the K8s namespace; the API needs the full name to pin to the right(deployment, build)pair). - Status panel stays empty.
demo-apiuses yourkubeconfigto read TWD status. Runkubectl get twd -n worker-controller-demofrom the same machine to confirm the context is right. - Self-hosted Temporal. Set
TemporalConnection.spec.hostPortto your frontend (e.g.temporal-frontend.temporal:7233) and follow the controller's configuration guide for TLS/mTLS. DropTEMPORAL_API_KEYfrom worker pods if unused.
activity/—probe_version,slow_stepworkflows/—PinnedDemo(A),AutoUpgradeDemo(B),RollbackWorkflow+RolloutGate(C),PinnedCanDemo(D)worker/— versioned worker with readiness probe on:8080api/— FastAPI service (demo-api)web/— Vite + React UI (proxies/apito the API)k8s/— exampleTemporalConnectionandTemporalWorkerDeploymentmanifests
