Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GitHub self-hosted runners on Azure Container Apps — Terraform demo

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 login context. 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

Why a PAT (not a GitHub App) for this demo

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-runner scaler 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.

Repository layout

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

Prerequisites

  • Azure: a subscription and Contributor on it; az CLI; Terraform >= 1.12.
  • GitHub Enterprise Cloud (ghe.com): your data residency subdomain (e.g. acme for https://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.com and to the runner image registry (Docker Hub by default).

Self-hosted runners are recommended for private repositories only.

Deploy (local, with the Azure CLI)

This demo deploys from your workstation using your az login context — there is no CI/CD pipeline. Two equivalent paths:

Option A — bootstrap script (recommended)

# Uses your local az login; the PAT is read securely and never written to disk.
./scripts/bootstrap.ps1 -GitHubEnterpriseSubdomain acme -GitHubEnterpriseSlug acme-inc

It 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).

Option B — Terraform directly

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 apply

Then run the demo workflow (see Verify below).

Remote state (optional)

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 you Storage Blob Data Contributor, writes terraform/backend.tf, and re-inits; or
  • cp terraform/backend.tf.example terraform/backend.tf, fill in your values, then terraform init (uses use_azuread_auth, so your az login needs data-plane access).

Configuration reference (key variables)

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

Verify the demo

  1. Add examples/selfhosted-demo.yml to a private repo inside your enterprise (its runs-on must match runner_labels).

  2. Trigger it (push or Run workflow). Within polling_interval_seconds, a job execution starts and the workflow completes.

  3. 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).

Teardown

cd terraform
terraform destroy

Ephemeral runners deregister themselves; any leftover offline entries age out in GitHub (Enterprise → Runners).

Security & production considerations

  • 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_ip output is the stable outbound IP to allow-list on GitHub Enterprise if you restrict runner network access.
  • Enterprise scaler API usage: ent scope enumerates repositories — set scaler_repos (and keep scaler_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.

How it maps to the code

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

About

Self-contained Terraform demo: GitHub Actions self-hosted runners on Azure Container Apps (KEDA, ephemeral, scale-to-zero)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages