- FAQ
GARM (GitHub Actions Runner Manager) is an open-source tool that manages GitHub Actions self-hosted runners. It automatically creates and destroys runner instances in response to workflow jobs, supporting multiple infrastructure providers (LXD, AWS, Azure, GCP, OpenStack, Kubernetes, and more).
- GitHub.com
- GitHub Enterprise Server (GHES)
- Gitea (1.24+)
Amazon EC2, Azure, CloudStack, GCP, Incus, Kubernetes, LXD, OpenStack, and Oracle OCI. You can also build your own provider.
SQLite3 only. The database is a single file on disk, requiring no external database server.
Yes. Set --min-idle-runners=0 on your pool. GARM will only create runners in response to queued jobs. Note that this adds startup latency (typically 1-3 minutes depending on your provider and image).
min-idle-runners-- GARM keeps this many idle runners ready at all times. When one picks up a job, a replacement is created immediately.max-runners-- The upper limit on total runners in the pool/scale set (idle + active). Once reached, no new runners are created until existing ones finish.
When a burst of jobs arrives, GARM creates runners for each queued job (up to max-runners). The job age backoff (default 30 seconds) prevents over-provisioning by giving existing idle runners time to pick up jobs first.
Not directly at the pool level. However, you can create an organization-level pool, which serves all repositories in that org. This effectively shares runners across repos.
Update the image while the pool is running:
garm-cli pool update <POOL_ID> --image=new-image:latestExisting runners continue using the old image until they're replaced. New runners use the updated image.
Optionally, you can recreate outdated idle runners by using the command:
garm-cli [pool|scaleset] runner rotate --outdated <[pool|scaleset]-id>- Disable the pool:
garm-cli pool update <POOL_ID> --enabled=false - Wait for active runners to finish, or delete idle ones:
garm-cli runner delete <NAME> - Delete the pool:
garm-cli pool delete <POOL_ID>
GARM has a bootstrap timeout (default 20 minutes). If a runner doesn't register with GitHub within this time, GARM marks it as failed and removes it. You can also force-delete:
garm-cli runner delete --force-remove-runner <RUNNER_NAME>When a job's labels match multiple pools, the pool balancer decides which pool to use:
- roundrobin (default) -- distributes evenly across matching pools
- pack -- fills the highest-priority pool first, overflows to others
Set via --pool-balancer-type when adding the entity (repo/org/enterprise).
Only if using pools (not scale sets). The webhook URL must be reachable by GitHub.com (or your GHES/Gitea server). For scale sets, no webhook is needed -- GARM subscribes to a GitHub message queue instead.
- Is the webhook URL reachable from GitHub? Check the webhook delivery status in GitHub settings.
- Does the webhook secret match? GARM validates every webhook payload.
- Is "Workflow jobs" selected as the event type?
- Check GARM logs:
garm-cli debug-log
Yes. Each GARM controller has a unique Controller ID. Multiple GARM instances can manage runners in the same repos/orgs without conflicts, as each tags runners with its Controller ID.
Check your endpoint URLs. GHES uses different URL patterns than github.com. Ensure --base-url, --api-base-url, and --upload-url are correct for your GHES instance.
If your GHES uses certificates signed by an internal CA, provide the CA certificate when creating the endpoint:
garm-cli github endpoint create \
--ca-cert-path /path/to/ca-cert.pem \
...You can also set a controller-wide CA bundle:
garm-cli controller update --ca-bundle /path/to/ca-bundle.pemDon't use Gitea's extended label syntax (label:docker://image) in pool tags — GARM treats tags as opaque strings and won't match them against the short label that Gitea sends in webhooks.
Instead, use the container workflow syntax, which works identically on both GitHub and Gitea:
jobs:
build:
runs-on: my-runner-label
container:
image: docker.gitea.com/runner-images:ubuntu-latest
steps:
- run: echo "Running in a container"This lets you use a single runner image (with Docker and Node.js installed) and run workflows on any container image. To ensure the required packages are available, set extra_specs on the pool:
garm-cli pool add \
--tags my-runner-label \
--extra-specs '{"extra_packages":["docker.io","nodejs"]}' \
...See the GitHub docs on running jobs in containers for more options (volumes, ports, environment variables). This syntax is supported by GitHub, Gitea, and Forgejo.
For more context, see #697.
- Cache the runner binary in your image -- this is the biggest win. See Performance.
- Disable OS updates during bootstrap:
--extra-specs='{"disable_updates": true}' - Use optimized storage -- for LXD, choose a storage driver with optimized instance creation.
- Enable shiftfs -- for LXD unprivileged containers.
The default is 20 minutes. Increase it if your provider or image takes longer to boot (e.g., bare metal with Ironic, large Windows images). Decrease it if you want faster detection of failed runners.
GARM uses JWT authentication. The API should be placed behind a reverse proxy with TLS termination for production use. All sensitive data (credentials, tokens) is encrypted at rest.
Always prefer the Controller Webhook URL. It's unique to your GARM instance and allows multiple GARM controllers to coexist in the same repo/org. The Controller Webhook URL includes the Controller ID in the path.
- Check that the Callback URL and Metadata URL are reachable from runner instances
- Look at runner status updates:
garm-cli runner show <RUNNER_NAME> - Check GARM logs for registration errors:
garm-cli debug-log
- Verify pool tags match the workflow's
runs-onlabels - Check that the pool is enabled:
garm-cli pool show <POOL_ID> - Check the job age backoff isn't too high:
garm-cli controller show - Verify max-runners hasn't been reached
- Check
garm-cli job listto see if GARM received the job