Skip to content

Comparison · ARC alternative

Why GitHub Actions Gateway over ARC?

Actions Runner Controller (ARC) struggles with one job: many runner sets, many tenants, one shared cluster, each tenant safely capped by its own ResourceQuota. GitHub Actions Gateway (GAG) is built for that job.

Get started Migrating from ARC See the architecture

When the job is disrupted

ARCgives up on it. You re-run it by hand.
GAGre-runs it. Measured 15–26 s, preemption or drain.

The problem ARC leaves you with

All four trace to one root, and it is not a missing feature. ARC models a cluster with one owner, so it has no primitive separating what the platform owns from what a tenant owns. That is a reasonable product for a single-tenant cluster, and the same gap from four angles once teams share one.

  • ResourceQuota is unsafe


    A quota-blocked or evicted job can't recover on its own:

    • claimed before the quota is known, so the runner cannot start
    • its single-use registration is already spent when quota is found
    • ARC retries every 30 s, recycling every 10 min (0.13.1)
    • the job reads assigned, so the 24 h queue timeout never fires
  • Critical jobs starve


    No way to reserve capacity for expensive runners:

    • each AutoscalingRunnerSet only caps itself with maxRunners
    • no primitive for "GPU always keeps N slots"
    • cheap CPU pods exhaust the quota; big tests stall
  • Listener pods pile up


    One always-on listener pod per scale set, running 24/7:

    • a pod slot and a pod IP each
    • held alive just to long-poll GitHub
    • the count tracks runner sets, not job volume
  • Platform team is the bottleneck


    Every tenant is a manual checklist:

    • namespace, quota, RBAC, scale sets, NetworkPolicies, egress
    • per-team setup; every later change is a ticket

What changes with GAG

Three capabilities that only pay off together, and what they unlock:

Capability Quota-aware intake a job the quota cannot place is never claimed
Capability Automatic re-run a worker the cluster takes away comes back
Capability · partly shipped Isolation by default PSA, default-deny egress, and Kata micro-VM workers ship today; untrusted-PR network isolation does not yet
What that unlocks Shared capacity is safe to actually use tight per-tenant quotas, bin-packing, and preemptible nodes stop being risks
Less ops toil no manual reruns, no stuck-job tickets
Fewer pages cluster churn stops being an incident
Higher utilization hardware you already pay for gets shared
More throughput jobs finish per node-hour instead of stalling

A tenant declares only namespaced resources; the GMC provisions the rest inside a platform-owned quota it cannot write, with no cluster admin after the install (the object set). The cost model runs the utilization argument in numbers, but a benchmark at scale is on the roadmap and not yet done: treat those figures as a model, not a measurement.

GAG vs ARC (scale-set mode)

This is not a protocol argument. GAG acquires jobs with the same runner-scale-set protocol ARC uses, shipped as the v2 default, so every row below is additive rather than a different-architecture trade-off. What differs is what surrounds that shared core.

Each ARC cell carries the chart version it was read at and the date it was read. A cell with no stamp asserts nothing; see how to read the ARC column under the table.

Capability ARC (scale-set mode) GitHub Actions Gateway
Runner-scale-set acquisition (single-acquirer, no fan-out) yes
0.14.2 · 2026-08-12
yes, by default v2
Ephemeral, single-use runner pods yes
0.14.2 · 2026-08-12
yes
Custom runner pod template & image yes
0.14.2 · 2026-08-12
yes
Bind a runner set to a named GitHub runner group yes, runnerGroup per scale set
a name that resolves to no group fails the reconcile rather than falling back to the installation default
0.14.2 · 2026-08-15
v2 runnerGroup, or one defaultRunnerGroup per gateway
the same failure, moved to admission, so the set is refused before it exists
Multi-label runner sets (a runs-on array matches) yes, since 0.14.0
runnerScaleSetLabels, deduplicated against the set name
0.14.2 · 2026-08-15
v2 every runnerLabel is registered
Workers scale to zero between jobs yes, by default
0.14.2 · 2026-08-12
yes, by default
Safe under a per-tenant ResourceQuota claims first, then discovers the quota
retries pod creation every 30 s and burns a fresh single-use registration every 10 min, indefinitely; the job reads as assigned, so GitHub's 24 h queue timeout never fires
0.14.2 · 2026-08-12
never claims a job it can't place
reads quota headroom before claiming, so nothing is spent and the job stays visibly queued until there is room
Auto-re-run jobs disrupted mid-flight (eviction / preemption / drain) no re-run mechanism exists
the runner registration is removed and the job is given up on; 0.13.1 recovers the pod, never the job
0.14.2 · 2026-08-12
re-runs itself in seconds
15–26 s measured for a preemption or drain; evictions, drains and hand-deleted workers all covered, under a per-run budget
Stop claiming jobs when the cluster can't place the worker acquires every available job unconditionally
the seat exists; no cluster state is consulted in it
0.14.2 · 2026-08-12
opt-in capacityGate
off by default. Bounds the rate of wasted claims to roughly one per pendingPodDeadline; it does not eliminate the first
Bound a stranded worker with no live controller pass-through only
activeDeadlineSeconds is copied from the pod template if an operator wrote one; nothing derives, reconciles or defaults a bound
0.14.2 · 2026-08-15
maxWorkerLifetime stamped on every worker
as its activeDeadlineSeconds, so the kubelet enforces it and workers stay bounded with the controller down
Guaranteed floor for critical runner types no reserved floor
ordering works, since priorityClassName reaches the runner pods; minRunners and maxRunners are per scale set, so neither holds a slice another set cannot spend (the two gaps)
0.14.2 · 2026-08-12
priority tiers per runner set
Throttle the rate new workers start (anti-stampede) no per-set start-rate control
its throttles are controller-wide and meter API calls, not worker onset
0.14.2 · 2026-08-12
opt-in per-set creation-rate limit
for shared-egress onset (NAT, firewall, VPN)
Pod Security Admission on tenant namespaces neither scale-set chart sets one
no pod-security.kubernetes.io/* label is templated, so a namespace keeps whatever policy the cluster already applied and the chart asserts nothing about it
0.14.2 · 2026-08-15
baseline per tenant namespace
reconciled rather than opt-in, in-tree PodSecurity, so no Kyverno or Gatekeeper is required
Default-deny worker network isolation ships no NetworkPolicy at all
neither scale-set chart templates one, so worker pods reach whatever the cluster default allows, cloud metadata included; the isolation is a separate project you build and keep in sync
0.14.2 · 2026-08-15
default-deny, reconciled per tenant
DNS and the tenant's own proxy only, re-derived as tenants come and go
Two tenants claiming one scale-set name binds to the existing set instead of failing
a set whose name already exists in the runner group is reused, with no check on who owns it, so both namespaces' listeners acquire from one queue
0.14.2 · 2026-08-15
admission refuses the pair
GitHub-wide; a collision carried in from an older release is reported on the gateway instead
Per-tenant dedicated egress IPs points at a proxy you already run
provisions no pool, manages no lifecycle, gives no per-tenant addresses
0.14.2 · 2026-08-12
provisioned per-tenant pool, live-validated on GKE
2026-07-13: one distinct, stable per-tenant NAT IP. A stable source IP needs Cilium Egress Gateway or cloud NAT beneath the pool, both specified v2
GitHub App private key kept out of the cluster the listener reads the key either way
the PEM sits in githubConfigSecret and is copied into the generated listener config Secret too; opt-in Azure Key Vault keeps it out of etcd, but the listener still fetches the key itself
0.14.2 · 2026-08-12
v2 opt-in workloadIdentity
an external signer signs the App JWT, so no App key exists in the cluster in any form. Its default githubApp member is the same in-cluster PEM as ARC's
Listener footprint at rest one always-on listener pod per scale set, each holding a pod IP
they run in the controller's namespace, so the cost lands on platform pod density rather than the tenant's quota
0.14.2 · 2026-08-12
1 shared pod, ~12 KiB per listener session
Control-plane availability out of the box one replica, no PodDisruptionBudget
replicaCount: 1, with leader election documented as enabled only above one; neither scale-set chart ships a PDB
0.14.2 · 2026-08-15
two replicas, leader election, PDB, anti-co-location
by default, and the lease is released on shutdown, so failover takes seconds rather than a lease timeout
Per-tenant utilization metrics opt-in, per scale set
ships commented out; carries a namespace label but nothing aggregates across sets, and no quota-headroom series
0.14.2 · 2026-08-12
per tenant and per runner set
plus a dashboard and 20 alert rules as code
Right-size runner resources from measured usage no feedback loop, so runner resources stay a guess
AutoscalingRunnerSet status carries runner counts and a phase, and nothing else
0.14.2 · 2026-08-12
v2 measured recommendations in RunnerSet status
+ opt-in profiles that auto-apply them at pod build (Binpack/Throughput/NodeShare), a SizingDrift condition, and per-job peak metrics
Warn before the runner agent falls below GitHub's minimum no version check exists
scale sets are created with DisableUpdate: true, so a pinned image stays pinned; the Outdated phase tracks spec drift, not agent version
0.14.2 · 2026-08-15
reported before GitHub enforces it
and an image reference naming no version says so rather than passing silently
Cross-tenant fleet health view (platform admin) one sample dashboard, no alert rules
nothing aggregates across scale sets or tenants
0.14.2 · 2026-08-12
single-pane fleet rollups
degraded, egress-stale and quota per gateway, plus a platform dashboard
Multiple gateways per namespace multiple AutoscalingRunnerSets
0.14.2 · 2026-08-12
v2 multiple scoped gateways per namespace
Reusable runner pod templates template inlined per AutoscalingRunnerSet
the chart takes one template: PodSpec per release; nothing shares it between sets
0.14.2 · 2026-08-12
v2 shared RunnerTemplate
cluster-wide ClusterRunnerTemplate

Every GAG capability above is available today. Rows marked v2 need the actions-gateway.com/v2beta1 API, which is where new tenants start. Which release a capability arrived in is on Features; the version selector at the top of the page switches between what each release shipped.

How to read the ARC column

Every ARC cell renders one of two things, and the difference is what it rests on:

  • a verdict ( or ) always carries a stamp naming the chart version it was read at and the date it was read;
  • unverified () means we believe the claim and have not checked it. No verdict is being asserted, and the cell carries no stamp.

There is no third case. A verdict without its stamp fails our own build, so the page cannot quietly go back to asserting things nobody measured.

ARC moves, and an undated comparison rots into a false one. This page used to carry one blanket measurement note for the whole column, which made a checked claim and an assumed one look identical, and two rows went false at datable releases with nothing to notice it: 0.13.1 (2025-12-23) changed how a quota-blocked pod creation is retried, and 0.14.0 (2026-03-19) added multi-label scale sets, which GAG matched in Q726. Under the per-cell stamp an aging claim degrades to unverified instead of to wrong.

What the current stamps were read against

Every cell above was read against ARC gha-runner-scale-set 0.14.2 (released 2026-05-22), at commit 9bb16ae: controller source and chart values, not the documentation. The stamps carry two dates because there were two passes: the original column on 2026-08-12, and the eight rows added on 2026-08-15, when 0.14.2 was still the newest published chart. The tag is what the stamp names; the commit is what makes it re-checkable, since a link to a branch drifts out from under the claim it was cited for. Per-cell evidence, file by file, is in the competitive analysis.

If you are evaluating on a later ARC than a cell names, re-check that cell rather than trusting it, and tell us what changed.

Two rows with fine print

Auto-re-run covers disruption, never failure. Eviction, preemption, a drain and a stray kubectl delete pod all come back, on both acquisition tiers. A job that failed and a run you cancelled never do. Nor do workers the reaper took, with one exception: a worker reaped while still Pending is re-run on both tiers once capacity returns. The full boundary.

Right-sizing is structural, not a feature race. An ephemeral pod runs one job and lives minutes, so stock Vertical Pod Autoscaler cannot size it. The loop only closes inside the controller that builds the pods. Appendix D.7.

Where ARC is ahead

Some of it is capability, not only maturity. Measured 2026-08-06. Each gap below is tracked, and what closes it and when is on the public roadmap; the support entitlement is the one we do not plan to match.

  • A GitHub Support entitlement, covering ARC installed via the official Helm charts, GitHub Enterprise Server 3.9 and later. GAG has none. Read the scope exclusions before relying on it: Kubernetes orchestration, policy application, and template customization are explicitly out of scope, which is much of what a multi-tenant platform team actually pages about.
  • containerMode: kubernetes, which runs container: and services: steps as separate pods with a provisioned volume. GAG runs one worker pod per job, so that path is Docker-in-Docker (under Kata, unprivileged) rather than a non-privileged pod-per-step model.
  • GHES that is actually tested. GAG serves GHES gateways and marks both of its GHES features untested against a real appliance.

The maturity gap is real too: ARC is GA and widely deployed, while GAG's v2 API has only just reached beta (v2beta1, its first stability contract). That is why the v1 to v2 migration runs on a committed, documented schedule with a working gag-migrate tool. The discipline is the "won't strand you" signal while the track record accumulates.

One thing is not a differentiator either way: both ride the same Public-Preview runner-scale-set protocol, through the same actions/scaleset client library.

Secure by default

Built for shared clusters running other teams' code: the multi-tenant hardening ships as reconciled defaults, not a post-install project.

  • Risk reduction


    Untrusted job code is boxed in by default:

  • Lower operational cost


    What you'd hand-build around ARC, reconciled from one CR:

    • NetworkPolicies · PSA · RBAC · egress
    • No Kyverno/OPA required: in-tree PodSecurity
    • Kept in sync as tenants come and go
  • Ready out of the box


    Secure by default; looser is an explicit opt-in:

    • Default-deny ingress, cluster-only DNS
    • Per-tenant egress IPs, mutual-TLS metrics
    • Signed images, SBOM, and SLSA provenance
    • Three validated worker templates, one kubectl apply -k

Sandboxing is not the runtimeClassName field

Both GAG and ARC can set one. The differentiator is the layer underneath.

Kata bounds the kernel, not the pod network, so cloud metadata still answers from inside the guest. GAG's default-deny NetworkPolicies close that path.

GAG's own CI runs this way, building a kind cluster inside a worker pod with zero privileged: true (how, and what Kata does not buy you).

Not yet a claim about untrusted pull requests, which need the egress work on the roadmap.

Threat model and abuse-response playbooks: Security, Security operations.