Migrating from Actions Runner Controller (ARC) to GitHub Actions Gateway (GAG)¶
Audience: Platform engineer running Actions Runner Controller (ARC) scale-set mode on a shared, multi-tenant Kubernetes cluster and evaluating GitHub Actions Gateway (GAG) as a replacement.
This guide maps ARC scale-set concepts onto GAG, calls out the behavioral
differences you will actually hit, and walks one runner group from ARC to GAG
end to end. It assumes you already run ARC's gha-runner-scale-set chart; if you
are new to GAG, read Why GAG and
Getting Started first, then come back here.
The good news up front: GAG was designed to make this migration cheap. The worker
pod template is the same Kubernetes type ARC uses, so your pod spec transfers
with no schema translation; job routing is the same single-name model ARC
scale sets use, so your runs-on lines need no edit; and ARC and GAG can run
side by side on the same cluster, so you migrate one scale set at a time and
roll back by pointing runs-on back at the old name.
Scope. This covers ARC's scale-set mode (the AutoscalingRunnerSet /
gha-runner-scale-set chart — GitHub's current and recommended mode), and targets
GAG's v2 API (actions-gateway.com/v2beta1), which is where new tenants
onboard. Legacy ARC (RunnerDeployment + HorizontalRunnerAutoscaler) maps
similarly, but its multi-label routing needs one extra step called out in
Job routing.
Do not migrate via GAG's v1 API. The older single-CR
actions-gateway.github.com/v1alpha1shape is deprecated and, more to the point here, it is Classic-protocol only — it would move you off the single-acquirer model your ARC scale sets already use. Migrate straight to v2.
The mental model¶
In ARC scale-set mode, each scale set is its own controller surface: one
AutoscalingRunnerSet CR, one long-running listener pod, its own
maxRunners cap, configured by its own Helm release. Ten runner types means ten
scale sets, ten listener pods, ten Helm releases.
In GAG, a tenant declares one ActionsGateway (identity and GitHub binding) and
one RunnerSet per runner type. A single per-gateway controller — the Actions
Gateway Controller (AGC) — multiplexes every set's listener as a goroutine
(~12 KiB) in one shared pod, instead of one always-on listener pod per scale set.
The platform installs the Gateway Manager Controller (GMC) once; from there a
tenant's whole gateway (controller, egress proxy, RBAC, NetworkPolicies) is
provisioned from those CRs.
So the shape of the migration is: N ARC scale sets in a namespace → 1
ActionsGateway + N RunnerSets in that namespace, sharing a RunnerTemplate
wherever the pod shapes are the same.
ARC (scale-set mode) GAG (v2 API)
───────────────────── ─────────────────────────────
namespace: team-a namespace: team-a
AutoscalingRunnerSet "cpu" ActionsGateway "team-a-gateway"
└─ listener pod (always-on) EgressProxy "team-a-egress"
AutoscalingRunnerSet "gpu" RunnerTemplate "default" (shared shape)
└─ listener pod (always-on) RunnerSet "cpu" (goroutine listener)
AutoscalingRunnerSet "arm" RunnerSet "gpu" (goroutine listener)
└─ listener pod (always-on) RunnerSet "arm" (goroutine listener)
(shared cluster egress) └─ one AGC pod multiplexes all three
└─ per-tenant egress proxy pool
Unlike GAG's deprecated v1 shape, v2 puts no one-gateway-per-namespace limit
on you: if your ARC namespace spans two GitHub orgs, that is two
ActionsGateways side by side, closer to how your scale sets are laid out today.
Concept mapping: ARC → GAG¶
| ARC scale-set concept | GAG equivalent | Notes |
|---|---|---|
AutoscalingRunnerSet (one per runner type) |
one RunnerSet CR |
1:1. Each keeps its own name, labels, and caps. |
gha-runner-scale-set Helm release per scale set |
plain CRs applied with kubectl |
You stop managing a Helm release per runner type. The GMC is the only Helm install (Install). |
gha-runner-scale-set-controller (cluster controller) |
Gateway Manager Controller (GMC) | Installed once by the platform; provisions every tenant's AGC. |
The AutoscalingListener pod (one per scale set) |
a goroutine inside the shared per-gateway AGC pod | one always-on pod per scale set → ~12 KiB/goroutine in one shared pod; no per-listener cluster IP. |
githubConfigUrl |
ActionsGateway.spec.githubURL |
Same org / enterprise / repo URL form. Immutable in v2 — no accidental rebinding of a live gateway. |
githubConfigSecret (PAT or GitHub App) |
ActionsGateway.spec.credentials |
GAG is GitHub-App-only — no Personal Access Token (PAT) path. See GitHub App setup. v2 also offers type: WorkloadIdentity to keep the signing key out of the cluster entirely. |
runnerScaleSetName / the name you put in runs-on |
RunnerSet.spec.runnerLabels (exactly one label) |
Same model. See Job routing. |
runnerGroup (the GitHub runner-group name) |
n/a | GAG registers a scale set per RunnerSet; there is no runnerGroup field. |
spec.template (pod template, a PodTemplateSpec) |
RunnerTemplate.spec.podTemplate (the same PodTemplateSpec) |
resources, nodeSelector, tolerations, affinity, topologySpreadConstraints, runtimeClassName, securityContext, volumes, init/sidecar containers transfer one-to-one. ARC inlines this per scale set; a RunnerTemplate is referenced by many RunnerSets, so identical shapes are written once. |
runner container image (ARC default ghcr.io/actions/actions-runner) |
RunnerTemplate.spec.workerImage (same image, or unset for the default) |
Drop-in. See Your runner image works unchanged. |
maxRunners (per scale set) |
RunnerSet.spec.maxWorkers + the namespace ResourceQuota |
The real cap is the platform-owned quota, shared across sets; maxWorkers is the per-set ceiling. See Quotas. |
minRunners (warm pool to mask cold start) |
n/a — not needed | The goroutine listener never goes cold, so there is no cold start to mask; GAG always scales workers to zero between jobs. Dropping minRunners > 0 is what removes your idle GPU/compute pods. |
containerMode: kubernetes / dind |
a template choice + the namespace security profile | GAG always runs one worker pod per job. Docker-in-Docker needs a platform-owned ClusterRunnerTemplate plus a privileged-eligible namespace; see Security profiles. |
Per-scale-set scaling math (minRunners/maxRunners) |
RunnerSet.spec.maxWorkers (+ optional sizing) |
One knob instead of two. Concurrency is advertised to GitHub as the scale set's capacity. |
| Pod Security / NetworkPolicy / RBAC hand-built around ARC | reconciled from the CRs by the GMC | GAG ships these as secure-by-default; you do not assemble them per tenant (Why GAG → Secure by default). |
| (no ARC equivalent) | RunnerSet.spec.priorityTiers |
A guaranteed floor of preempting slots for a runner type under a shared quota — the thing ARC's per-scale-set maxRunners cannot express. |
| (no ARC equivalent) | EgressProxy / dedicated egress IPs |
See Egress isolation. |
Your runner image works unchanged¶
Point workerImage at your existing ARC runner image — no rebuild — or leave it
unset to use the upstream ghcr.io/actions/actions-runner default. GAG's AGC
pre-acquires each job from the GitHub broker and hands it to the pod (rather than
letting the pod's runner register and poll), so the worker needs GAG's small
wrapper to feed the job payload into Runner.Worker. Rather than make you bake
that wrapper into your image, the AGC injects it into every worker pod at runtime
— a read-only OCI image volume on Kubernetes ≥ 1.33, an initContainer below that —
and runs it in place of the image entrypoint. The only requirement is that the image
be actions/runner-derived (it must contain Runner.Worker) — the same agent
requirement ARC has, so any image that works on ARC qualifies, with its tools and
userland intact.
Docker-in-Docker: because the injected wrapper runs in place of the image entrypoint, an
actions-runner-dindimage's bundleddockerdstartup is skipped. Run DinD as a sidecar container in a privilegedClusterRunnerTemplateinstead (see Security profiles) — GAG's DinD model regardless of the worker image.
Job routing: a 1:1 map¶
This is the section that used to carry a warning. Against GAG's v2 API it does not, and that is worth stating plainly because it is the difference between a migration that touches your workflows and one that does not.
ARC scale-set mode routes by the scale-set name. A workflow targets a scale set
with runs-on: <runnerScaleSetName> — a single name that equals the install name.
Scale-set mode deliberately dropped the arbitrary multi-label matching legacy ARC
RunnerDeployment had; the name is the selector.
GAG v2 routes the same way. A RunnerSet declares exactly one
runnerLabels entry, that label is the scale set's name at GitHub, and a workflow
targets it with that one label in runs-on. So carrying your ARC scale-set name
across as the label means existing workflows route unchanged:
# ARC: runs-on: gpu-large → GAG: runs-on: gpu-large (no edit)
spec:
runnerLabels: ["gpu-large"] # the ARC scale-set name, carried across verbatim
Rules worth knowing:
- Exactly one label. A set with two or more labels is rejected at admission — the scale set has one name, so it has one match target.
- Unique per gateway. Two
RunnerSets under oneActionsGatewaymay not share their label; they would register the same scale-set name at GitHub and collide. Keeping your ARC names gives you uniqueness for free, since ARC already required distinct install names. - No
self-hosted. Under the scale-set modelself-hostedis not part of the match, exactly as in ARC scale-set mode. Do not add it. - Labels are ≤ 256 chars and may not contain whitespace or commas.
Coming from legacy ARC (
RunnerDeployment+ multi-labelruns-on)? This is the one place your migration needs a workflow edit, and it is the same edit GitHub's own scale-set migration requires: multi-label matching does not exist in the scale-set model. Split eachruns-on: [self-hosted, linux, gpu]target into its ownRunnerSetwith a single descriptive label (gpu-linux), and update the workflows toruns-on: gpu-linux. Migrate to ARC scale sets first if you would rather do that change under ARC.
Egress isolation: the big difference¶
ARC runners share the cluster's egress path: GitHub (and any other) traffic leaves via whatever node / NAT the pod lands on, so you cannot attribute a source IP to a tenant or allow-list one team's runners without allow-listing the whole cluster.
GAG gives each tenant its own egress proxy pool (Tier 3), declared as an
EgressProxy and pointed at by the gateway's defaultProxyRef. All GitHub-bound
traffic from the AGC and worker pods routes through that pool, so every tenant
gets a dedicated, stable set of egress IPs never shared with another tenant. That
is what makes GitHub Enterprise Managed Users (EMU) IP allow-listing, per-tenant
incident attribution, and avoiding shared-NAT throttling possible. The pool is
HPA-managed between spec.minReplicas and maxReplicas.
Differences and quirks an ARC operator should know:
- The proxy is optional, but it is the reason to be here. A gateway with no
defaultProxyRefand sets with noproxyRefegress directly: still default-deny-restricted to DNS + the GitHub CIDR allowlist, but with no per-tenant IP attribution. The trade is surfaced asstatus.proxyMode: Directplus an advisoryEgressUnattributedcondition. If per-tenant egress IPs are what brought you off ARC, keep the proxy. See Proxy-less onboarding. - Default-deny egress is the default, and it needs a policy-aware CNI. GAG provisions NetworkPolicies that restrict worker pods to DNS + the tenant proxy only (no direct GitHub, no Kubernetes API), and the proxy pods to the GitHub IP allow-list + DNS. These rules are inert unless your Container Network Interface (CNI) plugin enforces egress NetworkPolicy — Calico or Cilium do; kind's default kindnet does not. If you ran ARC on a cluster whose CNI does not enforce egress, isolation is silently void until you switch CNI. Validate with the probes in network-architecture § How to Validate Network Isolation.
- The GitHub IP allow-list is maintained for you. The GMC refreshes the
GitHub CIDR set the proxy permits, so the egress allow-list tracks GitHub's
published ranges without operator action.
egressPolicyMode: FQDNallow-lists GitHub by hostname instead, where your CNI supports it. - Internal destinations need
noProxyCIDRs. Anything your jobs reach inside the cluster or your network (artifact stores, internal registries) must be excluded from the proxy viaEgressProxy.spec.noProxyCIDRs(CIDRs, bare IPs, orNO_PROXYdomain suffixes). Cluster-internal defaults are appended automatically on every distribution —svc.cluster.local,kubernetes.default.svc,localhost,127.0.0.1, and the cluster's API server ClusterIP — so neither the API server nor the service CIDR belongs in this field. Admission rejects any entry that would route GitHub traffic around the proxy (a host matchinggithubURLorgithub.com/githubusercontent.com/ghcr.io) — that would break egress-IP attribution. - The AGC↔proxy hop is HTTPS with a pinned cert. The GMC issues a per-tenant self-signed cert and pins it into the AGC trust store, so the proxy hop is not eavesdroppable by other tenants on the cluster.
Gotchas and behavioral differences¶
| Area | ARC scale-set behavior | GAG behavior | What to do |
|---|---|---|---|
| Evicted / quota-blocked job | Runner marked Failed; job sits in GitHub's queue until a manual rerun |
The job is cancelled when its lock lapses (~10 min at worst) and re-queued automatically; it runs as soon as capacity frees | Nothing — this is the headline upgrade. You can stop the manual-rerun runbook. |
| Job routing | runs-on: <scale-set-name> (single name) |
same single-name model | Carry the name across as the label; no workflow edit. |
| Auth | PAT or GitHub App | GitHub App only | Create a GitHub App if you were on a PAT. |
| Listener | one always-on pod per scale set, 24/7 | one shared goroutine pod per gateway | No action; expect far fewer pods/IPs at rest. |
| Warm pool | minRunners > 0 to mask cold start |
not needed; always scales to zero | Drop minRunners; this removes idle GPU/compute. |
| Per-runner-type cap | maxRunners per scale set |
maxWorkers per set + shared namespace ResourceQuota |
The quota is the real cap; size it for all sets (Quotas). |
| Critical-runner floor | none (each scale set caps only itself) | priorityTiers per set |
Optionally reserve preempting slots for expensive runner types. |
| DinD / privileged | containerMode: dind |
a privileged ClusterRunnerTemplate + privileged-eligible namespace |
Platform-granted, not tenant-settable; see Security profiles. |
| Pod template reuse | template copied into every scale set |
one RunnerTemplate referenced by many sets |
Deduplicate identical shapes; keep separate templates where they genuinely differ. |
| Gateways per namespace | many scale sets per namespace | many RunnerSets, and many gateways, per namespace |
No restructuring needed. (GAG's deprecated v1 API had a one-gateway limit; v2 does not.) |
| Quota / RBAC / NetworkPolicy | hand-assembled per tenant | reconciled from the CRs | Remove your bespoke policy manifests after cutover. |
| Worker-pod debugging | runner pod lingers per HRA config | finished pod kept for completedPodTTL (default 5m), then deleted |
Raise completedPodTTL if you need a longer kubectl logs window. |
| Maximum worker lifetime | none — a wedged runner pod runs until deleted by hand | maxWorkerLifetime (default 12h) as the pod's activeDeadlineSeconds, enforced by the kubelet |
Check this before cutover if any job runs longer than 12 hours. ARC has no equivalent cap, so a long job that worked there is killed here unless you raise the field. GitHub's own default timeout-minutes is 360 (6h), so this affects only jobs that explicitly opted past twice that. |
| Cluster version | ARC supports older clusters | Kubernetes ≥ 1.31 for v2 | The AGC selects its RunnerSets with a server-side CRD field selector that is alpha-off on 1.30. Confirm before you start. |
Quotas and scheduling¶
In ARC, each scale set's maxRunners is its own independent cap. In GAG, the real
ceiling is the platform-owned namespace ResourceQuota, shared across every
runner set, and maxWorkers is a per-set ceiling within it. This is deliberate — a
tenant-authored cap is no cap (the tenant could raise it), and it is what makes
per-tenant quotas safe: a quota-blocked job recovers instead of dying. Size the
quota for the proxy pool at maxReplicas plus worker pods up to each set's
maxWorkers; the
onboarding quota step
has the formula. When a set should never be starved by cheaper runners, use
priorityTiers to reserve a floor of preempting slots — the primitive ARC's
per-scale-set model has no equivalent for.
Security profiles¶
ARC's containerMode: dind becomes two platform-owned grants in GAG v2, neither
tenant-settable:
- A privileged namespace. The Pod Security level is a namespace label in
v2 (
actions-gateway.com/security-profile), shared by every gateway in the namespace.privilegedadditionally requires the namespace to carryactions-gateway.com/privileged-profile: allowed, applied by an administrator, or admission rejects it. - A privileged template. A namespaced
RunnerTemplatemay not declare privileged containers — a tenant must not self-author a privileged worker shape. Privileged DinD/sysbox/Kata shapes are published by a platform admin as a cluster-scopedClusterRunnerTemplate, which aRunnerSetreferences withtemplateRef: { kind: ClusterRunnerTemplate, name: … }.
The default baseline profile is correct for ordinary build/test workloads; use
restricted for high-isolation tenants. You can harden a profile in place freely
but relaxing it is an explicit annotated opt-in. Full rules:
tenant-onboarding Pre-Conditions and
Security § 5.3.
Worked migration: one scale set, end to end¶
This migrates a single ARC scale set to a GAG runner set with zero downtime — ARC keeps serving until you cut workflows over, and rollback is one line.
Worked example: an ARC scale set named gpu-large in namespace team-a,
authenticated with a GitHub App, running GPU workflows.
0. Confirm prerequisites¶
The GMC is installed with the v2 CRD chart (Install),
the cluster is Kubernetes ≥ 1.31, and its CNI enforces egress NetworkPolicy
(Egress isolation). Confirm the namespace is
marked a managed tenant, carries its security-profile label, and has a
platform-owned ResourceQuota — see the
tenant-onboarding Pre-Conditions. Leave
the ARC scale set running throughout.
1. Create the GitHub App Secret¶
If your ARC scale set used a GitHub App already, you can reuse the same App —
create a GAG-shaped Secret (appId, installationId, privateKey) in the tenant
namespace per
tenant-onboarding Step 1.
If your scale set used a PAT, register a GitHub App now (Actions: Read +
Administration: Read, installed on the same org/repos) — GAG has no PAT path.
Never copy a private key through an environment variable or shell history.
2. Translate the scale set into the v2 object set¶
Lift your ARC spec.template straight into the RunnerTemplate (same type), and
carry the scale-set name across as the runner set's single label so existing
runs-on keeps working:
apiVersion: actions-gateway.com/v2beta1
kind: EgressProxy
metadata:
name: team-a-egress
namespace: team-a
spec:
minReplicas: 2
maxReplicas: 10
---
apiVersion: actions-gateway.com/v2beta1
kind: RunnerTemplate # your ARC spec.template, written once and shared
metadata:
name: gpu
namespace: team-a
spec:
podTemplate: # ← paste your ARC spec.template here, unchanged
spec:
containers:
- name: runner
resources:
requests:
cpu: "4"
memory: "16Gi"
limits:
nvidia.com/gpu: "1"
---
apiVersion: actions-gateway.com/v2beta1
kind: ActionsGateway
metadata:
name: team-a-gateway
namespace: team-a
spec:
credentials:
type: GitHubApp
githubApp:
name: github-app-v2 # name-only Secret ref in this namespace
githubURL: https://github.com/team-a-org # was ARC's githubConfigUrl (immutable)
defaultProxyRef:
name: team-a-egress # every RunnerSet below inherits this
# No securityProfile field: the Pod Security level is a namespace label in v2.
---
apiVersion: actions-gateway.com/v2beta1
kind: RunnerSet
metadata:
name: gpu-large # v2 names the set explicitly
namespace: team-a
spec:
gatewayRef: { name: team-a-gateway }
templateRef: { name: gpu }
runnerLabels: ["gpu-large"] # the ARC scale-set name: runs-on: gpu-large still routes
maxWorkers: 20 # was ARC's maxRunners (real cap is the namespace quota)
# No minRunners: GAG scales to zero with no cold-start penalty.
Add one RunnerSet per ARC scale set you are migrating in this namespace — they
all point at the same ActionsGateway, and share a RunnerTemplate wherever the
pod shapes match. Where they differ, add a template per shape.
3. Apply and validate provisioning¶
Wait for Ready=True on the gateway and each runner set, and confirm the AGC,
proxy pool, RBAC, and NetworkPolicies came up — follow
tenant-onboarding Step 3 and
Step 4. At this point
both the ARC scale set and the GAG runner set are registered with GitHub;
GitHub routes a matching job to whichever acquires it first.
4. Cut workflows over and verify¶
Run a test job targeting the label and confirm a GAG worker pod is created,
egresses through the proxy, and is deleted on completion
(tenant-onboarding Step 5). Because
you carried the name across as the label, existing runs-on: gpu-large workflows
already land on GAG. Watch a few real jobs through the GAG path before removing
ARC.
5. Decommission the ARC scale set¶
Once you trust the GAG path, scale the ARC scale set to zero / uninstall its Helm release:
Repeat steps 2–5 for each remaining scale set, then remove any bespoke NetworkPolicy / RBAC / quota manifests you hand-built around ARC — GAG reconciles those from the CRs now.
Rollback¶
Nothing in this flow removes ARC until step 5, so rollback during migration is
trivial: if the GAG path misbehaves, keep the ARC scale set running and delete
the GAG RunnerSet (or point runs-on back at the ARC scale-set name if you gave
the GAG set a different label). Because both register independently with GitHub,
there is no split-brain to untangle.
Where to go next¶
- Tenant onboarding checklist — the full pre-conditions → first-job reference this guide builds on.
- Why GAG over ARC — the capability-by-capability comparison.
- Getting Started — the end-to-end first install.
- Network architecture — egress proxy and NetworkPolicy detail, with isolation-validation probes.
- Troubleshooting — common first-day failures.
- Deprecation and removal notice, covering what
v2.0.0removes (v1alpha1,v2alpha1, Classic acquisition). Relevant only if you are already on GAG's v1 API; new migrations should not start there.