Set up self-hosted GitHub Actions runners on Render
Overview
GitHub-hosted runners work well until a test suite needs a private database, an internal API, or a toolchain baked into a custom image. If your services already run on Render, you can run those CI jobs on Render too, next to the services they test. Render services connect to each other over private networking, which every workspace plan includes.
This guide uses Render's official example repo, render-examples/gha-runner, and follows its architecture as-is. The repo contains a Blueprint (render.yaml) and a Dockerfile. Together they deploy an ephemeral, self-hosted GitHub Actions runner as a Render background worker.
When you finish, GitHub lists your runner as Idle, and it picks up runs-on: self-hosted jobs. Those jobs can include docker build and docker run steps.
This guide covers build and test time: running CI jobs on Render infrastructure. Deploying your app to Render after CI passes is a separate topic.
How the example runner works
Each design choice in the example repo affects how you operate the runner later.
The runner is a Docker background worker
The Blueprint defines a single service with type: worker and runtime: docker, built from the repo's Dockerfile. A background worker runs continuously and receives no incoming network traffic. In practice, you don't open ports or configure a public URL. The runner connects out to GitHub itself.
Each instance runs one job, then re-registers
The runner is ephemeral. Each cycle follows this loop:
- Mint a fresh registration token from the GitHub REST API.
- Register with GitHub.
- Accept exactly one job and run it.
- Deregister.
- Loop back to step 1.
According to the README, this design follows GitHub's guidance for autoscaled self-hosted runners and is intended to give each job a clean environment. Each job starts from a fresh runner registration.
The tradeoff is concurrency. With one instance, only one job runs at a time, and parallel or matrix jobs wait in GitHub's queue. To run jobs in parallel, add instances. Scaling and cost explains how.
The entrypoint mints a fresh registration token each cycle
Before each cycle, the entrypoint uses the REST API to request a new GitHub registration token. You set GITHUB_PAT as an environment variable on the worker service. The README notes that this approach avoids token expiry issues.
This split keeps the credentials separate. The long-lived PAT stays on the worker as a secret, and registration only ever uses a token the entrypoint minted moments before.
In-flight jobs finish before shutdown
SIGTERM handling lets in-flight jobs finish within maxShutdownDelaySeconds before the runner deregisters. Render's default shutdown delay is 30 seconds, and the maximum is 300 seconds. The Blueprint sets maxShutdownDelaySeconds: 300 on the worker. To verify SIGTERM handling, manually trigger a redeploy while a test job runs, and watch whether it finishes gracefully or gets killed immediately.
Docker commands run through rootless Podman
The example image includes Podman configured in rootless mode, along with podman-docker. Workflow steps that call docker build or docker run execute through Podman, and they don't need privileged container access.
The example repo chose this container approach, and the README recommends validating it right after you deploy. Step 7 below walks through those checks.
Requirements
Before you start, confirm you have the following:
- A Render workspace where you already run services.
- A GitHub repository that uses Actions workflows. This setup is for private repositories only (see the warning below).
- A classic GitHub personal access token (PAT). The README calls for
reposcope and describes the minimum access asactions:writeon the target repo.
A classic PAT with repo scope grants broad access, and the README asks you to weigh that security tradeoff for your use case. Keep the PAT out of your repository, and store it only as a secret environment variable on the worker. The Blueprint marks GITHUB_PAT as sync: false for this reason, so it never syncs from the repo.
If your PAT has an expiry date, rotate it before it expires. The README suggests setting a calendar reminder. An invalid PAT is one of the listed reasons a runner fails to appear in GitHub.
Deploy the runner
Here's the full checklist. Each item has its own section below.
- Fork or clone the example repo
- Set
GITHUB_REPOinrender.yaml - Choose a compute plan
- Deploy the Blueprint
- Set
GITHUB_PATin the Dashboard - Confirm the runner shows Idle
- Validate Docker support
- Target the runner from a workflow
1. Fork or clone the example repo
Fork render-examples/gha-runner into your GitHub account or organization. You can also clone it and push it to a new repository. Render deploys the Blueprint from this copy, and you edit render.yaml in it in the next step.
2. Set GITHUB_REPO in render.yaml
Edit render.yaml and set GITHUB_REPO to the repository whose jobs the runner should accept, in owner/repo format. Here's the example repo's Blueprint, adapted with a placeholder GITHUB_REPO value and added comments:
You can adjust other fields, such as region or plan, to match your setup. Commit and push your changes.
3. Choose a compute plan
The Blueprint sets plan: standard. Container builds use a lot of CPU and RAM, so pick the plan before you deploy.
Plan | CPU | RAM | Price |
|---|---|---|---|
starter (0.5c-512mb) | 0.5 | 512 MB | $7/month |
standard (1c-2g) | 1 | 2 GB | $25/month |
2c-4g (formerly Pro) | 2 | 4 GB | $85/month |
If you want the 2 vCPU / 4 GB sizing the README describes, set plan to the 2c-4g plan instead of standard. Legacy plan names such as standard still work in Blueprint files.
Render introduced new compute plan IDs in August 2026, replacing the legacy instance type names (Starter, Standard, Pro, and so on). Each legacy name maps to a new plan ID with identical specs and pricing. The README's 2 vCPU / 4 GB description of standard doesn't match Render's current plan table.
Render prices checked between 2026-09-11 and 2026-11-05.
4. Deploy the Blueprint
In the Render Dashboard, open the Blueprints page and click New Blueprint Instance. Select your fork, granting Render access to it first if you haven't already. In the deploy window, click Approve. The worker builds and starts right away.
5. Set GITHUB_PAT in the Dashboard
Because GITHUB_PAT is marked sync: false, Render doesn't read it from the repo. To add it, go to your worker service, open Environment, and add GITHUB_PAT with your classic PAT. The worker picks up the change and starts registering.
6. Confirm the runner shows Idle
In GitHub, open your target repository and go to Settings > Actions > Runners. Look for a runner whose name starts with render-runner. Each runner moves through three states:
- Idle while it waits for a job.
- Active while it runs a job.
- Removed from the list after the job completes. This is the ephemeral cleanup.
If you see a runner in the Idle state, registration succeeded.
Billing: The worker runs continuously, even when it's idle between jobs, and it doesn't scale to zero. Render bills each running instance of a service, prorated by the second. Expect to pay the plan's full monthly price for as long as the runner is deployed, whether or not jobs run.
7. Validate Docker support
The README recommends running these checks immediately after you deploy to confirm that Podman can run containers in Render's sandbox. Open the Dashboard, select your worker service, open Shell, and run the commands below.
First, confirm the setuid bits on the user-namespace helpers:
Passing output shows -rwsr-xr-x. Output that shows -rwxr-xr-x means the setuid bit is missing.
Next, confirm that subordinate UID and GID ranges exist for the runner user:
Finally, run the container smoke tests:
These checks pass when the commands complete without Operation not permitted or a cgroup error. If a check fails, the README treats that as a sign that rootless containers aren't available on that plan, and it considers other approaches out of scope.
8. Target the runner from a workflow
Use runs-on: self-hosted to send a job to your runner. The runner registers with the labels self-hosted, render, linux, and x64. To make targeting more specific, list several labels in the runs-on array. This helps if you also run self-hosted runners somewhere other than Render.
Here's a minimal example that runs tests and builds an image. The docker commands run through Podman. Adapt the test command and image name to your project:
Push a commit, and the job appears as Active on your runner.
Operating the runner
Scaling and cost
Each instance runs one job at a time. With numInstances: 1 and one PAT, only one job runs at a time, so parallel or matrix jobs queue in GitHub. Render can run multiple instances of a background worker, and each service can scale to a maximum of 100 instances.
To add instances, set numInstances on the worker service in render.yaml, then commit and push the change. The field defaults to 1 for new services. You can also change the instance count from the service's settings in the Render Dashboard. See Scaling for details.
Each instance uses the same compute plan, and Render bills each one separately. Every instance also runs continuously while idle, because the worker doesn't scale to zero. For example, three instances on the standard plan cost 3 × $25 = $75 per month. Set your instance count to your typical parallel job demand, not to your peak matrix width.
Do
- If the PAT has an expiry, set a calendar reminder to rotate it before it expires.
- Rerun the step 7 checks after any plan change. If
newuidmap/newgidmaplack the setuid bit orpodman runfails withOperation not permittedor a cgroup error, rootless containers aren't available in Render's sandbox for this plan. - Use the full label set in
runs-onwhen you also have runners outside Render. - Test graceful shutdown once before you rely on it in production.
Don't
- Don't commit the PAT to
render.yaml. Thesync: falsesetting exists to prevent that. - Don't point this runner at a public repository.
- Don't expect matrix jobs to run in parallel on a single instance.
Troubleshooting
The first three rows come from the README's troubleshooting section. The last row and the runs-on label check are additional guidance.
Symptom | Likely cause | Fix |
|---|---|---|
Runner doesn't appear in Settings > Actions > Runners | Invalid or expired PAT, wrong GITHUB_REPO name, or GitHub API unreachable | Check the worker logs in the Dashboard. Regenerate the PAT with the scope listed in Requirements, correct GITHUB_REPO to owner/repo, and redeploy. |
Jobs queue but never start | Runner is stuck or has crashed | Check the worker logs and the shell output from the validation tests. Confirm that the runs-on labels match the runner's labels. |
Unsure whether graceful shutdown works | Behavior not yet verified | Trigger a redeploy while a test job runs. Watch whether the job finishes or gets killed immediately. |
docker build jobs run slowly or fail on memory | Plan too small for container builds | Move to a larger plan, then rerun the validation checks. |
Next steps
- Connect your CI jobs to private services and databases over private networking.
- Learn more about the background worker service type and scaling.
- Customize the Blueprint using the Blueprint (infrastructure as code) docs.
- Extend the example image's
Dockerfilewith the toolchains your jobs need.
Resources and links
render-examples/gha-runner: the Blueprint and Dockerfile that deploy the ephemeral runner worker- Background workers
- Private networking
- Blueprints
- Scaling
- Compute plans
- Render pricing