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 jobChanges per commitWhere it belongs
Base OS packages such as curl, wget, jq, build toolingNoImage
Pinned language toolchain or SDKOnly on upgradeImage
Vendor CLI or internal toolchain tarballOnly on releaseImage
Certificates, CA bundles, registry configurationRarelyImage
Project dependencies resolved from a lockfileYesCache
The application build and test stepsYesJob

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.

  1. 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.
  2. Pick a base image the requirements support, then bake your toolchain onto it with Packer or your existing image tooling.
  3. Build the image in your own account and tag it with a version.
  4. Add the image from the custom images page in the dashboard. Images in the region of your stack are listed there.
  5. 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.

.github/workflows/ci-image.yml
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.hcl

Point 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.

.github/workflows/ci.yml
jobs:
  build:
    runs-on: warp-custom-ci-base-4x
    steps:
      - uses: actions/checkout@v4
      - run: make build
      - run: make test

The 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:

PackageWhy it is required
curlArtifact and agent downloads
wgetArtifact and agent downloads
bashHook and setup script execution
jqRunner orchestration
libicuRequired 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.

  • aria2 must be present and reachable through the system PATH. 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 runneradmin user, 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 the runneradmin environment.

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_STARTED runs before each job.
  • WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED runs 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.sh

On 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:

  1. The script must end in .sh or .ps1. A .sh script runs with bash --noprofile --norc -e -o pipefail, falling back to sh -e when bash is unavailable. A .ps1 script is dot-sourced with pwsh -command, falling back to powershell -command.
  2. The path must be absolute.
  3. 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.
  4. 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.
  5. Permission errors usually mean the script is not executable. chmod +x /opt/ci/pre-job.sh during 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.

PathLabel shapeWarpBuild fee per minuteCompute billed by
Hosted Linux x64, 4 vCPU, 16GBwarp-ubuntu-latest-x64-4x$0.008WarpBuild
Hosted Linux x64, 8 vCPU, 32GBwarp-ubuntu-latest-x64-8x$0.016WarpBuild
Hosted Windows x64, 4 vCPU, 16GBwarp-windows-latest-x64-4x$0.016WarpBuild
BYOC Linux, custom imagewarp-custom-<name>$0.002Your cloud account
BYOC Windows, custom imagewarp-custom-<name>$0.002Your 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 monthFee removed at $0.008 per minuteFee removed at $0.002 per minuteImage pipeline fee per month
0.5600$4.80$1.20$0.42
1.51,800$14.40$3.60$0.42
33,600$28.80$7.20$0.42
67,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.