Custom Runner Images for GitHub Actions
Bake your toolchain into a cloud VM image, register it on a WarpBuild BYOC stack, and route GitHub Actions jobs to it with a warp-custom- runner label.
Last verified:
A custom runner image for GitHub Actions is a VM image you build in your own cloud account and register with WarpBuild, so a job starts on a machine that already carries your toolchain instead of installing it step by step. On WarpBuild this is a BYOC capability: you build the image, add it from the custom images page, attach it to a custom runner, and reference that runner in runs-on under its warp-custom- Runner ID.
This guide covers when a custom image earns its maintenance, what the image has to contain, how the pre-job and post-job hooks work, and what the change does to your bill. The cloud account setup that has to exist first is on the AWS BYOC page, and the requirement list is maintained in the custom VM images documentation.
Diagnosis
Every job that installs a tool pays for that install on every run. A custom image moves the install to build time, where it happens once per image version rather than once per job. The question is whether the minutes you reclaim are worth the pipeline you now maintain.
Symptoms that point at a custom image
- The same package installs sit at the top of several workflows:
apt-get install,choco install, a vendor CLI tarball, a JDK or .NET SDK that the base image does not carry. - A setup action downloads the same toolchain archive on every run because the tool cache in the base image does not hold that version.
- An install step reaches a private registry and needs a credential fetch before it can run, which adds a second failure mode to a step that produces no build output.
- Install steps fail on network flakes. A registry timeout marks a job red for reasons that have nothing to do with the change under test.
- Tool versions drift between workflows because each one pins its own install line, and a bug reproduces on one job and not another.
What belongs in the image
Sort each step by whether it changes with the commit.
| Step in the job | Changes per commit | Where it belongs |
|---|---|---|
| Base OS packages such as curl, wget, jq, build tooling | No | Image |
| Pinned language toolchain or SDK | Only on upgrade | Image |
| Vendor CLI or internal toolchain tarball | Only on release | Image |
| Certificates, CA bundles, registry configuration | Rarely | Image |
| Project dependencies resolved from a lockfile | Yes | Cache |
| The application build and test steps | Yes | Job |
Dependencies from a lockfile stay out of the image. They change too often to bake, and the cache handles them. Baking them produces an image rebuild on every dependency bump and a stale layer between rebuilds.
The break-even in install minutes per run
Take the arithmetic in two parts: what the image pipeline costs to run, and what the installs cost to keep.
An image build job that takes 12 minutes on warp-ubuntu-2404-x64-4x at $0.008 per minute costs $0.096 per rebuild. Rebuilt weekly, that is about $0.42 per month of runner fees (pricing page, checked on 2026-08-13).
Now the other side. At 1,200 workflow runs per month, one install minute per run is 1,200 runner minutes per month. Setting the two equal gives the break-even:
- Against the BYOC Linux fee of $0.002 per minute:
0.42 / (1200 * 0.002)is about 0.18 install minutes per run, roughly 11 seconds. - Against a hosted 4 vCPU rate of $0.008 per minute:
0.42 / (1200 * 0.008)is about 0.04 install minutes per run, roughly 3 seconds.
Because the fee break-even lands in seconds, maintenance minutes decide the question. The image pipeline needs an owner, a version scheme, a rebuild trigger when the base AMI gets security updates, and a rollback path. Teams that run this well usually set a practical bar higher than the fee break-even: bake when the installs you would remove run longer than about 90 seconds per job, or when the install step is a recurring source of red builds regardless of its length.
Two cases still argue against a custom image. If the tooling you need already ships in the WarpBuild runner images, changing the label is the cheaper move, and the hosted catalog covers Linux x64, Linux ARM64, macOS, and Windows runners. If the slow part is machine start rather than tool install, look at standby disks and the cold start guide first, because a fatter image does nothing for boot time.
Fix
The loop has five steps. Steps 1 and 2 are one-time per cloud account.
- Connect your cloud account and create a WarpBuild Stack, which fixes the region, VPC, and object storage the runners use. BYOC runs on AWS, GCP, and Azure.
- Pick a base image the requirements support, then bake your toolchain onto it with Packer or your existing image tooling.
- Build the image in your own account and tag it with a version.
- Add the image from the custom images page in the dashboard. Images in the region of your stack are listed there.
- Create a custom runner that uses the image, then reference the Runner ID in
runs-on.
Bake the image
A Packer template keeps the image reproducible and diffable in review. The shape below starts from an Ubuntu 24.04 base and installs the packages the runner agent needs, plus whatever your jobs were installing per run.
locals {
version = "1.4.0"
}
source "amazon-ebs" "ci-base" {
region = "us-east-1"
instance_type = "t3.micro"
ami_name = "ci-base-v${local.version}"
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.*-amd64-server-*"
root-device-type = "ebs"
}
owners = ["099720109477"]
most_recent = true
}
ssh_username = "ubuntu"
}
build {
sources = ["source.amazon-ebs.ci-base"]
provisioner "shell" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y curl wget jq unzip git libicu-dev",
"sudo mkdir -p /opt/hostedtoolcache",
"sudo chown -R runner:runner /opt/hostedtoolcache || true",
]
}
}Build it on a WarpBuild runner
The image build is itself a GitHub Actions job, so it runs on a normal runner label and leaves an audit trail in the Actions history.
name: build ci base image
on:
workflow_dispatch:
push:
branches: [main]
paths:
- "packer/ci-base/**"
permissions:
contents: read
id-token: write
jobs:
build-ami:
runs-on: warp-ubuntu-2404-x64-4x
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/ci-image-builder
role-session-name: build-ci-base
aws-region: us-east-1
- uses: hashicorp/setup-packer@v3
with:
version: 1
- run: packer plugins install github.com/hashicorp/amazon
- run: packer validate packer/ci-base/ci-base.pkr.hcl
- run: packer build packer/ci-base/ci-base.pkr.hclPoint workflows at the custom runner
Once the image is attached to a custom runner, the workflow change is one line. The Runner ID is the full name prefixed with warp-custom-, and the prefix is required.
jobs:
build:
runs-on: warp-custom-ci-base-4x
steps:
- uses: actions/checkout@v4
- run: make build
- run: make testThe install steps that used to sit above make build come out of the workflow in the same pull request. Keep the first migrated workflow on a branch for a week of real traffic before widening. When a job fails on the new image in a way the logs do not explain, the Action Debugger pauses the workflow and opens an SSH session on the runner, and CI observability correlates runner system metrics with the job logs.
Configuration
Linux image requirements
The distro the image is based on has to use systemd, because WarpBuild relies on it to run its agent. Ubuntu and Amazon Linux 2023 both qualify.
These packages must be present in the image:
| Package | Why it is required |
|---|---|
curl | Artifact and agent downloads |
wget | Artifact and agent downloads |
bash | Hook and setup script execution |
jq | Runner orchestration |
libicu | Required by the GitHub Actions runner as a .NET runtime dependency |
These come by default on every supported distro and must stay in the image: tar, gzip, coreutils (which provides id, chpasswd, chown, tr, and sed), shadow-utils (which provides useradd and usermod), and systemd. A minimal or hardened base that strips them produces a runner that registers and then fails on the first job.
The package name for libicu varies by distro version, so resolve it against your base image rather than copying a name across distros.
Windows image requirements
Windows AMIs add three requirements.
aria2must be present and reachable through the systemPATH. WarpBuild uses it to download the artifacts the runner needs, because the default Windows download path is slow.- On AWS, the EC2 instance must be sysprepped before you make a Windows image out of it. Both the EC2Launch GUI path and the Packer automation path are documented.
- Runners execute under the
runneradminuser, the same user GitHub's Windows runners use. If that user is not present, it gets added. User-scoped environment variables set under another account are invisible to the runner, so move them to machine level or set them in therunneradminenvironment.
Scripts before and after each job
GitHub self-hosted runners read ACTIONS_RUNNER_HOOK_JOB_STARTED and ACTIONS_RUNNER_HOOK_JOB_COMPLETED. WarpBuild already uses both internally to orchestrate runners for your jobs, so a custom image sets the WARPBUILD_ prefixed variables instead:
WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTEDruns before each job.WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETEDruns after each job.
Set them system-wide on the image so the runner agent picks them up at start. On Linux, add them to /etc/environment:
WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/ci/pre-job.sh
WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/ci/post-job.shOn Windows, set them as machine-level environment variables:
[Environment]::SetEnvironmentVariable(
'WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED',
'C:\ci\pre-job.ps1',
'Machine'
)The rules match GitHub's native hooks:
- The script must end in
.shor.ps1. A.shscript runs withbash --noprofile --norc -e -o pipefail, falling back tosh -ewhen bash is unavailable. A.ps1script is dot-sourced withpwsh -command, falling back topowershell -command. - The path must be absolute.
- The pre-hook runs after WarpBuild's own pre-hook and before the job. The post-hook runs after the job completes, whether the job passed or failed.
- A script that is missing, carries an unsupported extension, or exits non-zero fails the job. A failing pre-hook means the job never starts. Treat the hook script as production code and test it in the image build.
- Permission errors usually mean the script is not executable.
chmod +x /opt/ci/pre-job.shduring the image build.
Where the image runs
Custom VM images are a BYOC capability, so the image runs on instances in your cloud account under a custom runner configuration you own. That configuration also carries the instance types, disks, and IP settings. Listing several instance types in one runner configuration gives the runner a fallback when capacity for the first type is unavailable in the region, which matters most on spot.
The rest of the platform behaves the same on a custom image. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically, so a fleet on your own image scales with the queue rather than against a ceiling. The setup path itself is documented in the BYOC documentation, and the wider tradeoff between the hosted fleet and instances in your own account is worked through in hosted runners versus BYOC runners.
Cost or Time Model
There is no additional cost for using custom VM images. The line items are the BYOC runner fee, your own cloud bill for the instances, and the runner minutes the image pipeline consumes.
| Path | Label shape | WarpBuild fee per minute | Compute billed by |
|---|---|---|---|
| Hosted Linux x64, 4 vCPU, 16GB | warp-ubuntu-latest-x64-4x | $0.008 | WarpBuild |
| Hosted Linux x64, 8 vCPU, 32GB | warp-ubuntu-latest-x64-8x | $0.016 | WarpBuild |
| Hosted Windows x64, 4 vCPU, 16GB | warp-windows-latest-x64-4x | $0.016 | WarpBuild |
| BYOC Linux, custom image | warp-custom-<name> | $0.002 | Your cloud account |
| BYOC Windows, custom image | warp-custom-<name> | $0.002 | Your cloud account |
Rates from the pricing page, checked on 2026-08-13. BYOC add-ons such as cache and networking are included in the BYOC fee.
Worked model
Assumptions: 1,200 workflow runs per month, one job per run, I install minutes removed per run, a 12 minute image build rebuilt weekly at $0.008 per minute.
Install minutes removed per run (I) | Runner minutes removed per month | Fee removed at $0.008 per minute | Fee removed at $0.002 per minute | Image pipeline fee per month |
|---|---|---|---|---|
| 0.5 | 600 | $4.80 | $1.20 | $0.42 |
| 1.5 | 1,800 | $14.40 | $3.60 | $0.42 |
| 3 | 3,600 | $28.80 | $7.20 | $0.42 |
| 6 | 7,200 | $57.60 | $14.40 | $0.42 |
Measure I from your own step timings rather than the placeholder rows. The fee column understates the BYOC case, because instance time in your account comes out at the same rate whether the minute is spent installing tools or compiling code, so removing install minutes removes cloud spend as well as the WarpBuild fee.
The time column is the one engineers feel. At I of 3 and 1,200 runs, the change gives back 3,600 runner minutes per month, and 3 minutes of every developer's wait on every pull request.
What the change does not add
SSO is available for a flat $250 per month, whatever the user count, and it is the one line item on this page that does not move with usage.
FAQ
Does WarpBuild charge extra for custom VM images?
No. There is no additional cost for using custom VM images. You pay the BYOC runner fee of $0.002 per minute for Linux and $0.002 per minute for Windows, and your own cloud account bills the instance, disk, and data transfer at your rates.
Which images do the documented custom image requirements cover?
AMIs, Linux and Windows. The documented requirements describe what an AMI must carry to run WarpBuild runners on AWS. BYOC itself runs on AWS, GCP, and Azure, so check the custom VM images documentation before assuming the same requirement list applies to another cloud.
Why does ACTIONS_RUNNER_HOOK_JOB_STARTED have no effect on a WarpBuild runner?
WarpBuild already uses the GitHub-prefixed hook variables internally to orchestrate runners for your jobs. Set WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED and WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED instead, system-wide on the image, with absolute paths to scripts ending in .sh or .ps1.
How do I reference a custom runner image in a workflow?
Through the Runner ID of the custom runner that uses the image. The Runner ID is the full name prefixed with warp-custom-, and it goes in runs-on the same way any other runner label does.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.