A self-contained Terraform demo that runs GitHub Actions self-hosted runners as event-driven, ephemeral, scale-to-zero workloads on Azure Container Apps.
It implements the Microsoft-recommended pattern: an Azure Container Apps Job with an
Event trigger driven by the KEDA github-runner scale rule. When workflow jobs
queue in GitHub, KEDA starts runner replicas; each runner is ephemeral (registers,
runs one job, deregisters); when the queue drains, the job scales back to zero — you
pay only while jobs run.
Target platform for this demo: GitHub Enterprise Cloud with data residency (*.ghe.com),
enterprise-scoped runners, VNet-integrated and private.
Deployment is local-only — Terraform runs from your workstation using your
az logincontext. This repository is published as a reference archive and is not wired to a CI/CD pipeline.
GitHub Enterprise Cloud (acme.ghe.com) ── queued workflow jobs ──┐
│ KEDA github-runner scaler
│ polls api.acme.ghe.com (PAT)
┌────────────────────▼──────────────────────────┐
│ Azure Container Apps Environment (internal/VNet)│
│ Log Analytics · Consumption workload profile │
│ ┌────────────────────────────────────────────┐│
ephemeral runner registers to │ │ Container Apps JOB (trigger: Event) ││
acme.ghe.com (enterprise), runs ◄───────┼───┤ image: myoung34/github-runner ││
one job, then deregisters │ │ RUNNER_SCOPE=ent · EPHEMERAL=true ││
│ │ scale 0 → N via github-runner rule ││
│ └────────────────────────────────────────────┘│
└───────────────┬─────────────────────────────────┘
NAT gateway (stable egress IP) → *.ghe.com + Docker Hub
You may expect a GitHub App for least-privilege auth, but enterprise scope cannot use a GitHub App — on two independent layers:
- The KEDA
github-runnerscaler supports GitHub App auth only for repo/org scope. Enterprise (ent) scope requires a Personal Access Token. - Runner registration at enterprise scope also requires a PAT; GitHub App installation tokens are org/repo-scoped and cannot mint enterprise runner registration tokens.
So this demo uses a classic PAT with manage_runners:enterprise and repo scopes.
If you prefer GitHub App auth, switch to organization-scoped runners (runnerScope=org) —
both layers support GitHub App there.
terraform/ Terraform configuration (entry point)
scripts/ bootstrap.ps1 — local deploy helper (prereqs, az login, init/plan/apply)
examples/ selfhosted-demo.yml — sample workflow that targets the runners
- Azure: a subscription and Contributor on it;
azCLI; Terraform>= 1.12. - GitHub Enterprise Cloud (ghe.com): your data residency subdomain (e.g.
acmeforhttps://acme.ghe.com) and your enterprise slug. - Classic PAT with
manage_runners:enterprise+repo, created by an enterprise admin. - Outbound network access from the Container Apps subnet to
*.ghe.comand to the runner image registry (Docker Hub by default).
Self-hosted runners are recommended for private repositories only.
This demo deploys from your workstation using your az login context — there is no CI/CD
pipeline. Two equivalent paths:
# Uses your local az login; the PAT is read securely and never written to disk.
./scripts/bootstrap.ps1 -GitHubEnterpriseSubdomain acme -GitHubEnterpriseSlug acme-incIt verifies prerequisites, selects your subscription, prompts for the runner PAT, then runs
terraform init/plan/apply. Useful switches: -SubscriptionId, -AutoApprove, -PlanOnly,
and -CreateBackendStorage (provision an Azure Storage remote-state backend).
cd terraform
cp terraform.tfvars.example terraform.tfvars # then edit values (do NOT commit it)
# Provide the PAT out-of-band rather than putting it in a file:
export TF_VAR_runner_github_pat="ghp_xxx" # PowerShell: $env:TF_VAR_runner_github_pat="ghp_xxx"
az login
terraform init
terraform plan
terraform applyThen run the demo workflow (see Verify below).
By default Terraform uses local state. To use an Azure Storage backend instead, either:
- run the bootstrap with
-CreateBackendStorage -BackendResourceGroupName rg-tfstate -BackendStorageAccountName <globally-unique-name>— it provisions the account/container, grants youStorage Blob Data Contributor, writesterraform/backend.tf, and re-inits; or cp terraform/backend.tf.example terraform/backend.tf, fill in your values, thenterraform init(usesuse_azuread_auth, so youraz loginneeds data-plane access).
| Variable | Default | Purpose |
|---|---|---|
github_data_residency_subdomain |
— (required) | ghe.com subdomain → acme.ghe.com / api.acme.ghe.com |
github_enterprise_slug |
— (required) | Enterprise to register runners against / scale on |
runner_github_pat |
— (required, sensitive) | Classic PAT (manage_runners:enterprise + repo) |
runner_labels |
["self-hosted","aca-demo"] |
Labels matched by runs-on and the scaler |
runner_group |
null |
Optional enterprise runner group |
scaler_repos |
[] |
Repos the scaler polls — set at enterprise scope to bound API calls |
runner_image / runner_image_tag |
myoung34/github-runner / 2.335.1-ubuntu-noble |
Runner image |
runner_cpu / runner_memory |
2.0 / 4Gi |
Per-replica resources |
min_executions / max_executions |
0 / 10 |
Scale-to-zero floor / concurrent runner cap |
polling_interval_seconds |
30 |
Scaler poll cadence |
replica_timeout_seconds |
1800 |
Max runtime per runner replica |
create_virtual_network |
true |
Create VNet+subnet, or BYO via existing_subnet_id |
existing_subnet_id |
null |
BYO subnet (delegated to Microsoft.App/environments) |
environment_internal |
true |
Internal (no public ingress) environment |
create_nat_gateway |
true |
Stable egress IP for GHE allow-listing |
log_analytics_workspace_id |
null |
BYO workspace, else one is created |
-
Add
examples/selfhosted-demo.ymlto a private repo inside your enterprise (itsruns-onmust matchrunner_labels). -
Trigger it (push or Run workflow). Within
polling_interval_seconds, a job execution starts and the workflow completes. -
Watch executions:
az containerapp job execution list \ -n "$(terraform -chdir=terraform output -raw runner_job_name)" \ -g "$(terraform -chdir=terraform output -raw resource_group_name)" -o table
Runner logs flow to the Log Analytics workspace (
ContainerAppConsoleLogs_CL).
cd terraform
terraform destroyEphemeral runners deregister themselves; any leftover offline entries age out in GitHub (Enterprise → Runners).
- PAT lifecycle: classic PATs expire — rotate the runner PAT (
TF_VAR_runner_github_pat) before expiry. For the demo the PAT is a Container Apps job secret; in production reference it from Key Vault via a user-assigned identity. - Image supply chain: the default image is public on Docker Hub. For production, mirror it into ACR (avoids Docker Hub rate limits and gives provenance control) and pull with a managed identity.
- Egress allow-listing: with the NAT gateway, the
egress_public_ipoutput is the stable outbound IP to allow-list on GitHub Enterprise if you restrict runner network access. - Enterprise scaler API usage:
entscope enumerates repositories — setscaler_repos(and keepscaler_enable_etags = true) to bound GitHub API rate-limit consumption. - Private repos only; no Docker-in-Docker (Container Apps jobs cannot run Docker, so
workflow steps that call
docker build/services will fail). - Ephemeral, one-shot runners (
EPHEMERAL=true,replica_retry_limit = 0) — the secure default; a fresh runner per job.
| Concern | Where |
|---|---|
| GHE.com URL derivation, scaler metadata, runner env | terraform/locals.tf |
| VNet, subnet delegation, NAT gateway, Log Analytics, ACA environment, runner job | terraform/main.tf |
| Inputs and validation | terraform/variables.tf |
Outputs (incl. egress_public_ip, verify_hint) |
terraform/outputs.tf |