Ken Muse

Custom GitHub Runner Images With Pre and Post Job Scripts


If you’ve been waiting for a way to build your own GitHub-hosted runner images, the public preview delivers exactly that. You can now use custom images on GitHub hosted runners that contain your favorite tools. In this guide we’ll walk through the end-to-end experience, highlight the places where the preview can surprise you, and sprinkle in a few tips from the field so you launch confidently. I’ll also show you how to add pre- and post-job scripts that prepare the machine before the first step and clean up after the job closes.

Why custom runner images matter

You probably may have experienced this: every workflow run installs the same tooling, downloads the same SDKs, and burns minutes before your first compile even begin. In some cases, this can also create egress from your cloud environment, increasing those costs. Custom images let you pre-bake those dependencies, trimming minutes off each build, and standardizing the toolset across your organization.

This preview isn’t just a clone of self-hosted runners. You’re still using GitHub’s infrastructure (VMs and networking), but you get significantly more control over what’s on the machine. GitHub’s official guidance outlines the new capabilities in the custom image documentation. Some potential use cases include:

  • Blocking unexpected workflows before they start by using a pre-job script
  • Caching tools or Actions on the runner so that they are available immediately
  • Changing environment defaults
  • Restricting or customizing network access
  • Pre-installing security patches, compliance tools, or custom certificates
  • Adding additional monitoring or security processes
  • Caching very large repositories or datasets to speed up builds
  • Caching known-good dependencies or OCI images to reduce supply chain risks, improve reliability, speed up builds, or reduce egress costs

Step 1: Build a larger hosted runner that can capture snapshots

Start in your organization settings under Actions > Runners. Choose New runner, then select New GitHub-hosted runner. Pick the hardware size you want and give the runner a label (for example, larger-runner-demo). Be aware that the runner size you pick for the builds must be the same size as the runner you plan to run the custom image. If you’re using Linux, you’ll have two sets of base image options:

  • GitHub-owned Ubuntu gives you the fully loaded image you already know from ubuntu-24.04 images, including languages, SDKs, and utilities.
  • Partner base images for Ubuntu in the partner catalog are slimmer builds designed to let you have more control over what goes into the image.

The key checkbox is Enable this runner to generate custom images. Without it, your workflow will finish successfully but no snapshot will ever appear or be listed on the Custom images tab. You won’t even see an error.

Configuration screen for a larger hosted runner

Step 2: Author the image-building workflow

Next, you need to create a workflow that runs on the new larger hosted runner and customizes the virtual machine.

  1. Create a workflow file (for example, .github/workflows/custom-image.yml).
  2. Set runs-on to the label you assigned in Step 1 so the job schedules on the snapshot-capable runner.
  3. Add a snapshot block with an image-name and (optionally) a version major number.
  4. Use the job’s steps list to configure the machine exactly the way you want it captured. Install any dependencies, run hardening scripts, and clean up caches you don’t want baked into the snapshot.

Here’s a starter workflow you can drop into .github/workflows/custom-image.yml.

 1name: Build custom runner image
 2
 3on:
 4  workflow_dispatch:
 5  schedule:
 6    - cron: '0 9 * * 1'
 7
 8jobs:
 9  bake-image:
10    runs-on: larger-runner-demo
11    timeout-minutes: 120
12    snapshot:
13      image-name: tools-lts
14      version: 1.*
15    steps:
16      - name: Install additional tooling
17        run: |
18          sudo apt-get update
19          sudo apt-get install -y yq graphviz
20
21      - name: Remove package cache
22        run: sudo apt-get clean

The snapshot block tells GitHub to capture the VM after the final step succeeds. The catalog entry will be the image-name, and version provides the major version for the tag. GitHub auto-increments the minor segment every time you snapshot (sorry, no patch versions allowed!). You can also use the simple form (snapshot: tools-lts) to let GitHub handle versioning entirely.

Step 3: Add pre- and post-job scripts

If you’ve ever worked with self-hosted runners, then you may be aware you can run scripts before the first workflow step and after the final one finishes using job scripts. While they are running, they have access to the standard environment variables listed in the default environment variable reference. That means you can leverage those to make decisions or create configurations specific to the job immediately before it executes.

You’ll also find handy variables on the stock Ubuntu environments, including ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE (the location of the Actions archive cache), AGENT_TOOLSDIRECTORY (which matches RUNNER_TOOL_CACHE and stores the location of the tool cache), ImageOS (such as ubuntu24), ImageVersion (the base image version, such as 20251112.124.1), and the USER account for the runner.

The steps for generating an image with these scripts:

  1. Place your hook scripts in a folder on the runner, for example /opt/runner/pre-job.sh and /opt/runner/post-job.sh.
  2. Mark them executable with chmod +x.
  3. Register them by exporting two environment variables that the Actions runner reads at runtime. The easiest way to do that is to append them to /etc/environment. This is a privileged file, so you’ll want to use sudo tee to write to it.

Here’s how this might look within a workflow:

 1- name: Add pre-script
 2  run: |
 3    mkdir -p /opt/runner
 4    cat << EOF > /opt/runner/pre-script.sh 
 5    #!/usr/bin/env bash
 6    echo '##### PRE SCRIPT #####'
 7    printenv
 8    EOF
 9    sudo chmod +x /opt/runner/pre-script.sh
10    sudo tee -a /etc/environment > /dev/null << EOF
11    ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/runner/pre-script.sh
12    EOF
13    
14- name: Add post-script
15  run: |
16    cat << EOF > /opt/runner/post-script.sh
17    #!/usr/bin/env bash
18    echo '##### POST SCRIPT #####'
19    printenv
20    EOF
21    sudo chmod +x /opt/runner/post-script.sh
22    sudo tee -a /etc/environment > /dev/null << EOF
23    ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/runner/post-script.sh
24    EOF

Both hooks execute using the runner’s user account (typically runner). Remember that a non-zero return code or error in these scripts will fail the job. It’s also worth mentioning that the environment variables written to /etc/environment are available to the workflow’s shell for every job (in this case, as Bash environment variables such as $SOMEVAR). They are not included in the env context (per ADR-0278) if you’re using expressions, such as ${{ env.SOME_VAR}}.

Step 4: Wait for the image to provision

When the job completes, GitHub moves the snapshot through a few phases before it’s ready for reuse. These phases are visible in Organization settings > Actions > Runners > Custom images when you click on the image name.

  • Generating
    GitHub captures the disk image immediately after your job finishes successfully.
  • Provisioning
    The snapshot is copied, replicated to regions, and deployed to GitHub-hosted runners. You’ll see messages such as Copying image, Provisioning image, and Replicating image to regions in the UI. The full process normally takes 1-3 hours to complete. After the image has been provisioned, it will start to appear as an option in the runner configuration.
  • Ready
    The entire process has finished and the image is published.

Two versions showing generating and ready status

Step 5: Consume the custom image in other runners

After your snapshot reaches the Ready state, you can use it to create your customized runner pool.

  1. Create another larger hosted runner from Actions > Runners > New GitHub-hosted runner.
  2. Switch to the Custom tab and select the image captured in Step 2.
  3. Choose a specific version or stick with latest if you want GitHub to roll forward automatically as you publish images.

Selecting a custom image when creating a new runner

From there, using the image feels just like any other runner. Reference the runner label in runs-on and GitHub handles the rest.

1jobs:
2  build:
3    runs-on: larger-runner-tools-lts
4    steps:
5      - uses: actions/checkout@v4
6      - run: npm ci && npm test

If you’re using the pre- and post-job scripts, you’ll see the workflow executes similar to a custom runner:

  • Set up job
    GitHub downloads and configures Actions, and it logs details about the image and its version under VM Image
  • Set up runner
    This step is executing your ACTIONS_RUNNER_HOOK_JOB_STARTED script
  • All of your job steps are executed
  • Complete runner
    The ACTIONS_RUNNER_HOOK_JOB_COMPLETED script runs to perform any cleanup.
  • Complete job
    GitHub finalizes the job and kills any orphaned processes. The virtual machine shuts down and the contents of the disk are wiped.

The fine print

Custom images are powerful, but there are a few important things to keep in mind.

  • This is a public preview and could change before general availability. I’m not expecting any changes, but that doesn’t mean they won’t happen.
  • Larger hosted runners with custom images are billed at the usual per-minute rate every time your workflow runs. In addition, the custom image itself accrues storage charges for each version you keep. That means that you will want to define a lifecycle policy and delete older images that you no longer need.
  • A snapshot is only compatible with the runner size that produced it. If you create the image on a 4-core Linux runner, every runner that uses that image must also be 4-core Linux machine.
  • The storage footprint of the image matches the underlying runner. For example, a 2-core Linux runner offers 75 GB of storage, so each snapshot from that machine consumes 75 GB even if your customizations only use half the space. This is because VMs require storing the entire disk image.

Thanks for exploring the preview with me! Hope that you find this new capability as exciting as I do. Now you can secure your build environments and create more predictable, faster workflows by bottling up your best configurations into custom images.