Skip to content

Multi-tenant runner platform for Kubernetes

Self-hosted GitHub Actions with zero idle compute

No idle workers. Disrupted jobs auto-retry. Enforceable quotas.

GitHub Actions Gateway (GAG) is an Actions Runner Controller (ARC) alternative for shared, multi-tenant clusters.

Get started Watch the demo Why GAG? View on GitHub

Drop-in for your existing setup: jobs target the same runner labels, so nothing in your .github/workflows changes.

helm install gag \
  oci://ghcr.io/actions-gateway/charts/actions-gateway \
  --version 1.3.0 \
  --namespace gmc-system --create-namespace \
  --set gmc.image.digest=sha256:<gmc> \
  --set agc.image.digest=sha256:<agc> \
  --set proxy.image.digest=sha256:<proxy> \
  --set wrapper.image.digest=sha256:<wrapper>

# v2 API (recommended): apply the signed, pre-rendered CRDs
kubectl apply --server-side -f \
  https://github.com/actions-gateway/github-actions-gateway/releases/download/v1.3.0/actions-gateway-crds-v2.yaml

What GAG gives you

These ladder up one way: safe quotas and self-healing disruption make shared capacity usable, which is what lets you bin-pack expensive nodes and run on preemptible capacity. Estimate your savings vs ARC → · See every feature →

1 pod Listener footprint at rest: all listeners are goroutines in one shared pod; ARC runs one always-on pod per set
15–26 s Measured recovery from a preemption or drain: the run concludes and re-runs itself, with no manual rerun and no ticket
0 Idle GPU pods between jobs: workers exist only while a job runs, deleted on completion
20 Alert rules shipped as code, with a tenant dashboard and a platform dashboard beside them
  • Tenant self-service under quotas


    Quotas you can safely enforce:

    • Platform-owned quota cap
    • Blocked jobs auto-recover
    • Zero manual reruns
    • Self-serve ActionsGateway, no ticket per change
  • No blocked critical jobs


    Reserve capacity for key runners:

    • Reserve N slots per runner type
    • CPU tests can't starve GPU jobs
    • Critical tests always schedule
  • No idle GPUs


    Pods live only for the job:

    • Created on acquire
    • Deleted on completion
    • GPU freed the instant a job ends
    • Scale-to-zero by default
  • Isolated egress IPs


    Each tenant's own proxy pool:

  • Lower listener overhead


    Listeners are goroutines, not pods:

    • ~12 KiB per listener session
    • One shared pod per tenant
    • ARC: one always-on pod per set
  • Per-tenant observability


    Scoped visibility, no cluster access:

    • Prometheus per tenant + group
    • Grafana dashboards + alerts, as code
    • Job counts in kubectl get
    • K8s Events on job transitions
    • Cross-tenant fleet rollups for platform admins
  • Shared runner templates v2


    Define once, reference by name:

  • Secure by default


    Hardening reconciled by default:

    • baseline Pod Security Admission per namespace
    • Default-deny NetworkPolicies
    • Credentials never in env vars
    • Workload identity keeps the App key out
    • Signed images, SBOM, and SLSA provenance
    • Kata micro-VM workers, proven in our own CI
  • Right-size from measured usage v2


    No more guessed resources:

Who GAG is for

GAG targets a specific audience: teams that must self-host runners and run them for many tenants on one cluster. If that's you, here's the value per segment.

  • Platform & developer-experience teams


    Multi-tenant CI on a shared cluster:

    • Enforce a per-team quota without stranding jobs
    • Tenants self-serve from one ActionsGateway
    • No ticket queue for every runner change
  • Orgs that must self-host


    Driven by a hard constraint, not preference:

    • Compliance or data-residency requirements
    • EMU or firewalled-service IP allow-lists
    • Per-tenant egress IPs you allow-list directly
  • GPU / ML platform teams


    Done paying for accelerators between jobs:

    • Workers scale to zero, so no idle GPU
    • GPU nodes return to the scheduler on completion
    • Priority tiers keep critical GPU jobs scheduling

Not your setup? Three cases where something else wins, and we would rather say so.

  • A vendor can run your jobs → a managed runner service, on speed and setup
  • Managed Kubernetes is cheap, CI fits one cloud → a cluster per team isolates harder
  • One team owns the cluster and the runnersARC, whose protocol GAG is built on

GAG is for big, expensive nodes that several teams must share, safely. Compare the options →

How it fits together

A four-tier system: a cluster-scoped manager gives each tenant an isolated gateway from its ActionsGateway. Jobs are acquired with the same single-acquirer runner-scale-set protocol ARC uses, through the same actions/scaleset client library, and it is the shipped default. So runs-on keeps working and the protocol is not the difference. What differs is that the acquisition decision lives in the control plane rather than in the runner pod, which is what lets a job be declined before it is claimed.

Tenant input ActionsGateway resource one per tenant · namespace-scoped
Tier 1 Gateway Manager Controller cluster-scoped · installed once
Tier 2 Actions Gateway Controller goroutine multiplexer
Tier 3 Egress proxy pool per-tenant egress IPs
Tier 4 Ephemeral worker pods one per job · GC'd on completion

Read the architecture overview for the full breakdown, jump to why GAG over ARC, browse every feature, or see the public roadmap for what's next.

What a tenant actually declares

The whole object set for a proxied gateway with a GPU runner set (priority tiers) and a Linux runner set. Every resource is namespaced, none is cluster-scoped, and no ResourceQuota field appears anywhere: the quota is platform-owned and set on the namespace, so it is a cap the tenant cannot raise.

apiVersion: actions-gateway.com/v2beta1
kind: EgressProxy               # (1)!
metadata:
  name: team-a-egress
  namespace: team-a
spec:
  minReplicas: 2
  maxReplicas: 10
---
apiVersion: actions-gateway.com/v2beta1
kind: RunnerTemplate            # (2)!
metadata:
  name: default
  namespace: team-a
spec:
  podTemplate:
    spec:
      containers:
        - name: runner
---
apiVersion: actions-gateway.com/v2beta1
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/v2beta1
kind: RunnerSet
metadata:
  name: gpu
  namespace: team-a
spec:
  gatewayRef:  { name: team-a-gateway }
  templateRef: { name: default }   # (4)!
  runnerLabels: ["gpu"]         # (5)!
  priorityTiers:                # (6)!
    - priorityClassName: runner-critical
      threshold: 5
    - priorityClassName: runner-standard
      threshold: 20
---
apiVersion: actions-gateway.com/v2beta1
kind: RunnerSet
metadata:
  name: linux
  namespace: team-a
spec:
  gatewayRef:  { name: team-a-gateway }
  templateRef: { name: default }
  runnerLabels: ["linux"]
  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 (PSA) 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. Exactly one label per runner set: it is the set's scale-set name at GitHub and its single runs-on match target (runs-on: gpu), unique across the sets under one gateway. A single-name runs-on carries over from ARC unchanged; a workflow targeting an array needs one edit per target, covered in migrating from ARC.
  6. 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 (a watched PriorityClassAllowlist CR, grown without a GMC restart; the --allowed-priority-classes flag remains the fail-safe baseline), and whether a tier preempts is set on the platform-owned PriorityClass object, so 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, and removed at v2.0.0; 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.