Skip to content

Comparison · ARC alternative

Why GitHub Actions Gateway over ARC?

Actions Runner Controller (ARC) scale-set mode struggles with one job: running many runner sets, for many tenants, in one shared cluster — cost-effectively, with each tenant safely capped by its own ResourceQuota. GAG was built for exactly that, without giving up the self-service that makes a shared cluster worth running.

Get started Migrating from ARC See the architecture

When a worker is evicted or blocked by a full ResourceQuota

ARCthe runner is marked Failed and the job sits in GitHub's queue until someone reruns it by hand
GAGthe job lock is fast-cancelled and the job re-queued automatically — it runs as soon as capacity frees up, no manual rerun

The problem ARC leaves you with

The failures compound, but they all trace back to one root: ARC's poor fit with ResourceQuota makes per-tenant quotas unsafe — and unsafe quotas are what block letting tenants run their own runners.

  • ResourceQuota is unsafe


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

    • ARC retries the same runner (30 s loop), then marks it Failed
    • the job sits in GitHub's queue up to its 24-hour timeout
    • cleared and rerun by hand (#4155, #4203) — so teams avoid enforcing quotas
  • 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 memory piles up


    One .NET listener pod per scale set, running 24/7:

    • ~256 MiB resident + a cluster IP each
    • held alive just to long-poll GitHub
    • 10 scale sets ≈ 2.5 GiB before a job runs
  • Platform team is the bottleneck


    Every tenant is a manual checklist:

    • namespace, quota, Role-Based Access Control (RBAC), scale sets, NetworkPolicies, egress
    • per-team setup; every later change is a ticket

What changes with GAG

600 KiB Listener memory for 10 runner sets — one shared pod, versus ~2.5 GiB across 10 on ARC
0 Idle GPU pods between jobs — workers exist only while a job runs, deleted on completion
Auto Recovery for quota-blocked and evicted jobs — the lock is fast-cancelled and the job re-queued, no manual rerun
1 Namespace a tenant self-serves in — declare your gateway and runner sets; the GMC provisions the controller, proxy pool, RBAC, and network policies to run within the platform-owned quota, no per-tenant cluster-admin

GAG vs ARC (scale-set mode)

GAG acquires jobs with the same runner-scale-set protocol ARC uses — a single acquirer per runner set, capacity-gated assignment, no many-acquirers fan-out — and it is the shipped default in the v2 API. So the comparison below is capability-for-capability against ARC's own model: every GAG row is additive, not a different-architecture trade-off. The difference is what surrounds the shared acquisition core — quota safety, priority tiers, per-tenant egress, and control-plane footprint.

Capability ARC (scale-set mode) GitHub Actions Gateway
Runner-scale-set acquisition (single-acquirer, no fan-out) yes yes, by default v2
Ephemeral, single-use runner pods yes yes
Custom runner pod template & image yes yes
Workers scale to zero between jobs yes, with minRunners: 0 yes, by default
Safe under a per-tenant ResourceQuota quota-blocked jobs stall; manual cleanup + rerun auto lock-cancel + re-queue
Guaranteed floor for critical runner types no per-quota primitive priority tiers per runner set
Throttle the rate new workers start (anti-stampede) only maxRunners caps the count — a burst starts all at once opt-in scaleUp creation-rate limit per set
for shared-egress onset (NAT / firewall / VPN)
Per-tenant dedicated egress IPs shared cluster egress per-tenant proxy pool
v2 proxy optional
Listener memory, 10 runner sets at rest ~2.5 GiB across 10 pods ~600 KiB in 1 shared pod
Per-tenant utilization metrics scale-set metrics, not tenant-scoped Prometheus per tenant + group
job counts in kubectl get; ready-to-apply tenant dashboard + alerts as code
Cross-tenant fleet health view (platform admin) controller + per-scale-set metrics, aggregated by hand; no bundled dashboard single-pane GMC fleet rollups
degraded / egress-stale / quota per gateway, + a platform dashboard
Multiple gateways per namespace multiple AutoscalingRunnerSets v2 multiple scoped gateways per namespace
Reusable runner pod templates template inlined per AutoscalingRunnerSet v2 shared RunnerTemplate
cluster-wide ClusterRunnerTemplate

Every capability above is available today.

Onboarding: start on v2

New tenants should onboard on the recommended v2 API (actions-gateway.com/v2alpha1 — a decomposed ActionsGateway + RunnerSet + RunnerTemplate, with an optional standalone EgressProxy); the rows marked v2 are v2-only. The single-CR v1alpha1 shape shown below is still fully served but deprecated — see the v1 → v2 migration guide and the getting-started walkthrough for the v2 object set.

The numbers behind these claims

For limits and Service Level Objectives, see Appendix A — Capacity Targets & SLOs; for the utilization-and-cost argument, Appendix F — Cost model.

Where GAG is behind ARC

It's maturity, not capability. ARC is GA and widely deployed; GAG's recommended v2 API is still alpha and rides a Public-Preview runner-scale-set protocol. That is precisely why the v1 → v2 migration is handled 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.

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:

    • baseline Pod Security Admission (PSA) per namespace
    • Default-deny network — DNS + own proxy only
    • App keys read-only; never in env, never cached
    • Controller writes confined to tenant namespaces
  • 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 + Software Bill of Materials (SBOM) + Supply-chain Levels for Software Artifacts (SLSA) provenance

For the full threat model, per-profile controls, and the abuse-response playbooks, see Security and Security operations.

Composable building blocks, not one giant CR

A tenant still declares only namespace-scoped resources, and the Gateway Manager Controller (GMC) provisions the controller, proxy pool, RBAC, and network policies to match — all within the platform-owned ResourceQuota the GMC never creates or mutates, with no per-tenant cluster-admin after the initial install. What changed with the recommended v2 API is that the single-CR monolith is decomposed into small, reusable kinds — and that decomposition is a differentiator ARC's inlined, per-scale-set model structurally can't express:

  • Reuse the pod shape


    One RunnerTemplate — or cluster-wide ClusterRunnerTemplate — is referenced by every RunnerSet. ARC inlines the pod template into each AutoscalingRunnerSet, so N runner types means N copies to keep in sync.

  • Clean ownership boundary


    Platform owns the quota, the PriorityClass allowlist, and cluster templates; the tenant composes RunnerSets within them. ARC has no primitive to separate platform-owned from tenant-owned concerns.

  • Egress on purpose


    A standalone EgressProxy is referenced by the gateway (or per RunnerSet), or dropped entirely for direct — still NetworkPolicy-restricted — egress. ARC has no per-tenant egress primitive at all.

  • Many gateways, one namespace


    Multiple scoped ActionsGateways coexist in a namespace, each with its own GitHub binding and runner sets — not one CR that must own everything.

The v2 object set below is feature-equivalent to the legacy single-CR example — a proxied gateway with a GPU runner set (priority tiers) and a Linux runner set:

apiVersion: actions-gateway.com/v2alpha1
kind: EgressProxy               # (1)!
metadata:
  name: team-a-egress
  namespace: team-a
spec:
  minReplicas: 2
  maxReplicas: 10
---
apiVersion: actions-gateway.com/v2alpha1
kind: RunnerTemplate            # (2)!
metadata:
  name: default
  namespace: team-a
spec:
  podTemplate:
    spec:
      containers:
        - name: runner
---
apiVersion: actions-gateway.com/v2alpha1
kind: ActionsGateway            # (3)!
metadata:
  name: team-a-gateway
  namespace: team-a
spec:
  credentials:
    type: GitHubApp
    githubApp:
      name: my-github-app       # name-only Secret ref in this namespace
  githubURL: https://github.com/team-a-org
  defaultProxyRef:
    name: team-a-egress         # every RunnerSet inherits this unless it sets proxyRef
---
apiVersion: actions-gateway.com/v2alpha1
kind: RunnerSet
metadata:
  name: gpu
  namespace: team-a
spec:
  gatewayRef:  { name: team-a-gateway }
  templateRef: { name: default }   # (4)!
  runnerLabels: ["gpu", "self-hosted"]
  maxListeners: 10
  priorityTiers:                # (5)!
    - priorityClassName: runner-critical
      threshold: 5
    - priorityClassName: runner-standard
      threshold: 20
---
apiVersion: actions-gateway.com/v2alpha1
kind: RunnerSet
metadata:
  name: linux
  namespace: team-a
spec:
  gatewayRef:  { name: team-a-gateway }
  templateRef: { name: default }
  runnerLabels: ["linux", "self-hosted"]
  maxWorkers: 30
  1. Optional. A standalone per-tenant egress proxy pool, Horizontal Pod Autoscaler (HPA)-managed between these bounds; all GitHub traffic exits through it on dedicated IPs. Drop it (and defaultProxyRef) for direct, still NetworkPolicy-restricted egress — collapsing the minimum to three objects.
  2. A reusable pod shape referenced by both RunnerSets below via templateRef. Define it once; a cluster-scoped ClusterRunnerTemplate shares one shape across every namespace. The Pod Security Admission level is a namespace label in v2, not a CR field — all gateways in a namespace share one level.
  3. credentials.githubApp.name references a Secret in this namespace holding the GitHub App appId, installationId, and privateKey. The GMC watches the reference name, not the Secret contents — see credential rotation. WorkloadIdentity is the opt-in no-PEM credential member.
  4. Both runner sets reference the same RunnerTemplate. There is no ResourceQuota field on any of these CRs — the single quota every runner set shares is platform-owned, set on the namespace by the platform admin, so it is a real cap the tenant cannot raise. Priority tiers decide who wins when it is contended.
  5. The first 5 GPU pods get the higher-priority PriorityClass; the next tier bursts opportunistically; the final threshold caps total concurrency. The priorityClassName values must be on the platform's allowlist (the GMC --allowed-priority-classes flag), and whether a tier preempts is set on the platform-owned PriorityClass object — a tenant cannot name a class that evicts other tenants' pods.

The legacy single-CR v1alpha1 shape — which expresses this whole gateway in one ActionsGateway CR — is still fully served but deprecated; see the getting-started walkthrough for it and the v1 → v2 migration guide to move across without changing how your jobs are acquired.

Ready to try it? Follow the getting-started guide. Already running ARC? The Migrating from ARC guide maps every concept above onto GAG and walks one runner group across with zero downtime.