On 17 September 2026 the AKS engineering blog published Running GitHub Actions Runner Controller on AKS Automatic by Steve Griffith. It is a clean guide: create an AKS Automatic cluster, install Actions Runner Controller (ARC), register a runner scale set against a GitHub repository, run a workflow, watch it scale.
I followed it from a subscription that had never run AKS Automatic, and this post is what happened, command by command, with the real output. Most of it went exactly as written. Three things did not, and those are the useful parts.
Repo: github.com/sathpal/arc-aks-automatic-demo. One make target per step, a screenshot of each, a troubleshooting section for everything that went wrong, and the validation workflow. Fork it, set your GitHub user in .env, and the runners register against your fork.
What AKS Automatic changes
A standard AKS cluster gives you a Kubernetes API and lets you decide the rest. AKS Automatic decides for you: node auto-provisioning (Karpenter) instead of hand-sized node pools, Azure RBAC for Kubernetes with local accounts disabled, managed Prometheus and Grafana, and Deployment Safeguards, which are Gatekeeper policies that block or warn on common anti-patterns such as pods without resource requests or images tagged latest.
ARC is a good test of that. It has a controller, a listener per runner set, and ephemeral runner pods that appear when a job is queued and vanish when it finishes. The runner pods are exactly the kind of bursty workload node auto-provisioning is for, and the charts are exactly the kind of upstream Helm charts that Safeguards will have opinions about.
Step 0: two things the blog assumes you already have
A current Azure CLI. The guide's first command is az extension add --name aks-preview --upgrade. On my machine that pulled an aks-preview build that needs a newer CLI than the 2.72 I had, and every az aks command then died with a Python traceback. The --sku automatic flag is GA and lives in the core CLI, so the fix was not the extension but the CLI itself:
az extension remove -n aks-preview
brew upgrade azure-cli # 2.72.0 -> 2.90.0
az aks create --help | grep -A2 -- --sku
Sixteen vCPUs of quota in one VM family that AKS Automatic likes. My first create attempt failed after a minute with:
AKS Automatic could not find a suitable VM size. The subscription may not have the
required quota of '16' vCPUs, may have restrictions, or location 'australiaeast' may
not support three availability zones for the following VM sizes: 'standard_d4lds_v5,
standard_d4ads_v5, standard_d4ds_v5, standard_d4d_v5, standard_d4d_v4, standard_ds3_v2,
standard_ds12_v2, standard_d4alds_v6, standard_d4lds_v6, standard_d4alds_v5'
Every D-family in every region I checked was at the default limit of 10 cores. The fix was a quota request through the CLI, which was approved instantly for one family:
az extension add -n quota
az quota update \
--resource-name standardDaldv6Family \
--scope "/subscriptions/<sub-id>/providers/Microsoft.Compute/locations/australiaeast" \
--limit-object value=48 --resource-type dedicated
The same request for the DSv5 family came back "ContactSupport", so pick a family that is cheap and new; the v6 AMD sizes were the ones that went through for me.
Step 1: create the cluster
export LOCATION=australiaeast RG=rg-arc-auto-lab CLUSTER=arc-auto-lab
az group create --name $RG --location $LOCATION
az aks create --resource-group $RG --name $CLUSTER --location $LOCATION \
--sku automatic --no-ssh-key
This took 33 minutes, not the ten I had budgeted from experience with standard AKS. Automatic creates a three-node system pool spread across zones, a hosted pool for its own add-ons, Karpenter, managed monitoring, and the policy stack, then waits for all of it to be healthy.
Local accounts are disabled, so you give yourself a Kubernetes RBAC role through Azure and use kubelogin:
AKS_ID=$(az aks show -g $RG -n $CLUSTER --query id -o tsv)
ME=$(az ad signed-in-user show --query id -o tsv)
az role assignment create --assignee $ME \
--role "Azure Kubernetes Service RBAC Cluster Admin" --scope $AKS_ID
az aks get-credentials -g $RG -n $CLUSTER --format exec --overwrite-existing
kubectl get nodes -o wide
Six nodes on day one: three nodepool1 system nodes and three hostedpool nodes that AKS runs for its managed components. All on Azure Linux 3.0.
Step 2: install the controller
The controller chart has no resource requests by default, and Deployment Safeguards will refuse a pod without them. The blog's values file fixes that:
# arc-controller-values.yaml
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
helm upgrade --install arc \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
--namespace arc-systems --create-namespace --wait --timeout 10m \
-f arc-controller-values.yaml
It installs, but Safeguards has more to say than the blog mentions. The install printed warnings that the controller container has no liveness or readiness probe, and that anti-affinity and topology spread constraints were added to the deployment by mutation. Those are warn-level policies, so the install goes through. The ones in deny mode, such as resource requests, are the ones that would have stopped it:
If you want the probe warning gone, the controller chart accepts livenessProbe and readinessProbe values. I left it as the blog has it so the output matches.
Step 3: token and runner scale set
kubectl create namespace arc-runners --dry-run=client -o yaml | kubectl apply -f -
printf '%s' "$GITHUB_TOKEN" | kubectl create secret generic github-pat \
--namespace arc-runners --from-file=github_token=/dev/stdin
A classic PAT with repo scope is enough for a repository-level runner set. The blog says, and I agree, that anything beyond a lab should use a GitHub App so the permission is narrow and rotation is not a person's job.
The runner set values need three things to be explicit for Safeguards: resource requests on the listener, resource requests on the runner, and a pinned runner image tag. The default chart uses ghcr.io/actions/actions-runner:latest, and Safeguards rejects floating tags.
# arc-runner-set-values.yaml
githubConfigUrl: https://github.com/<owner>/<repo>
githubConfigSecret: github-pat
minRunners: 0
maxRunners: 3
listenerTemplate:
spec:
containers:
- name: listener
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:2.337.0
command: ["/home/runner/run.sh"]
resources:
requests: { cpu: "2", memory: 4Gi }
limits: { cpu: "2", memory: 4Gi }
The blog pins 2.336.0. By the time I ran this, 2.337.0 was current, and GitHub refuses runners more than a few versions behind, so check the releases page before you copy a tag.
helm upgrade --install arc-auto-runners \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
--namespace arc-runners --wait --timeout 10m -f arc-runner-set-values.yaml
A listener pod appears in arc-systems and long-polls GitHub's broker for jobs. With minRunners: 0 nothing else runs until a job arrives, and the repository's runner list stays empty. That is normal and briefly alarming.
Step 4: run a workflow and watch it scale
The validation workflow from the blog, unchanged, committed to .github/workflows/arc-automatic-validation.yml with runs-on: arc-auto-runners, which is the Helm release name and therefore the runner label.
gh workflow run arc-automatic-validation.yml --repo <owner>/<repo> --ref main
I polled the run status, the runner pods and the node count every ten seconds:
What happened in those 190 seconds:
- t+19s the listener saw the queued job and created one ephemeral runner pod. It went Pending: the system nodes are tainted for system workloads and the one schedulable node did not have 4Gi free.
- t+53s node auto-provisioning launched a new node for it. Karpenter nominated the pod onto the node claim before the VM existed.
- t+121s the node was ready and the pod pulled the runner image, which is about a gigabyte and took 52 seconds.
- t+173s the job ran. It took 8 seconds.
- t+190s the run reported success, the pod was gone, and the runner count was back to zero.
The node claim output is the part worth staring at. Karpenter picked a Standard_D4as_v6 on demand for the runner, and within minutes of the job finishing it flagged that node as a consolidation candidate, launched a cheaper D4als_v6 replacement and moved the controller and listener onto it, quoting the saving. Nobody configured any of that.
On the GitHub side it looks like any other run:
Current runner version: '2.337.0'
Runner name: 'arc-auto-runners-j4kzc-runner-x8p8j'
ARC runner reached workflow execution
Linux arc-auto-runners-j4kzc-runner-x8p8j 6.6.150.1-1.azl3 #1 SMP x86_64 GNU/Linux
Docker version 29.7.2, build a7dcaa6
Validation complete
The total of 2 minutes 48 seconds is almost entirely cold start: a new VM and a cold image pull. A second job arriving while the node is still there would start in seconds. If that latency matters, set minRunners: 1, and accept that you now pay for one idle D4.
Step 5: clean up
helm uninstall arc-auto-runners -n arc-runners
helm uninstall arc -n arc-systems
kubectl delete namespace arc-runners arc-systems
az group delete --name rg-arc-auto-lab --yes --no-wait
The resource group delete also removes the node resource group AKS created.
One more thing I learned by doing it wrong. I moved the runner set from one repository to another with helm upgrade and a new githubConfigUrl. The listener crashed on every start with "No runner scale set found with identifier 1": the set had kept the ID it registered under the first repository, and the second one had no such ID. Uninstall and reinstall is the only clean path, and if a stale AutoscalingListener object survives that, delete it and the controller recreates it in seconds.
Where I landed
If someone on my team asked whether to run our Actions runners this way, I would say yes, with two caveats that are about Azure rather than ARC.
The ARC side is boring in the best sense. Steve's values files are exactly what the cluster demands, the charts install first time, and the runner behaves like a hosted runner from the workflow's point of view. I did not change a line of the workflow. What I spent my time on was getting a cluster at all: the quota round trip and the CLI upgrade took longer than everything after them combined, and neither is mentioned in the post because a Microsoft engineer's subscription does not have those problems. Yours might. Run az vm list-usage for your region before you type az aks create, and check the CLI version before you add any extension.
The thing that changed my mind about AKS Automatic was not the cluster creation, which is slow, or Safeguards, which mostly nag. It was watching Karpenter after the job finished. It had bought a D4as_v6 for the runner, noticed twenty minutes later that the node was underused, priced a cheaper D4als_v6, launched it, moved the pods, and deleted the original. It printed the saving in the event log. I have written that logic by hand for other clusters and never got it this tidy.
The caveat on the ARC side is the token. I used my own PAT because it was a lab and I wanted to get to the interesting part. Do not do that for a team. A GitHub App gives the runner set its own identity with the permissions it needs and nothing else, and rotation stops being someone's memory.
Try it
gh repo fork sathpal/arc-aks-automatic-demo --clone && cd arc-aks-automatic-demo
make tools && cp .env.example .env # set GITHUB_OWNER to your user
make quota && make cluster && make access && make arc
export GITHUB_TOKEN=$(gh auth token) && make runners && make test
make cleanup









Top comments (0)