This topic may sound familiar, but this post intentionally goes beyond what you’ll find in AWS documentation or official blog posts.
The goal is to avoid webhooks and instead achieve maximum flexibility when using GitHub Actions for CI/CD with AWS CodeBuild-powered, ephemeral runners only when they are actually needed, while continuing to rely on GitHub-hosted runners for everything else.
Webhooks — one-way door
I’m not saying webhooks are wrong. If you have a clear reason to use them, they can be a good fit. However, in practice, they often become a one-way route that reduces flexibility — and I strongly prefer fit-for-purpose solutions.
Relevant AWS documentation:
- https://docs.aws.amazon.com/codebuild/latest/userguide/action-runner-overview.html
- https://aws.amazon.com/blogs/devops/aws-codebuild-managed-self-hosted-github-action-runners/
The main issue with the webhook option is that you subscribe to specific GitHub event types, which effectively pushes you toward using CodeBuild for all jobs — unless you introduce increasingly complex filters and workflow logic.
At that point, your GitHub Actions configuration starts encoding infrastructure decisions, which is rarely ideal.
On-demand & ad-hoc runners — flexibility
Instead, there’s a way to make CodeBuild-powered self-hosted runners available explicitly for the workflows that need them — and only when they’re actually required.
The idea is to start a CodeBuild project on demand, scoped to a specific GitHub Actions workflow run, and configured so it can only be used by that run. This avoids clashes, ghost runs, or unintended usage across repositories within your GitHub organisation.
Consider a setup where:
- some workflows only build artifacts,
- others run unit tests or static analysis,
- others perform deployments.
Most of these can run perfectly fine on GitHub-hosted runners. However, there are cases where that breaks down:
- running tests against IP-allowlist-protected public endpoints,
- running tests against private endpoints accessible only from within a custom VPC.
In the first case, some teams attempt to add GitHub runners’ public IP ranges to allowlists. This creates a false sense of security, as those endpoints remain accessible to a large, shared address space.
In the second case — private endpoints — it’s simply a hard stop.
So how do you bring both worlds together and cover all of these use cases?
Architecture overview
In this architecture, a GitHub App is created with its associated private key, enabling secure authentication with one or more repositories. AWS CodeBuild leverages this identity to generate temporary tokens and register itself dynamically as an ephemeral GitHub Actions runner.
This approach builds on the standard GitHub -> AWS OIDC integration, as documented here.
GitHub App
A key part of making this pattern robust and production-ready is how the GitHub runner registration token is obtained. While many examples rely on a Personal Access Token (PAT), using a GitHub App is the more appropriate choice for team and organisation-level setups.
A GitHub App can be installed on specific repositories only, which aligns well with the idea of tightly scoped, purpose-built runners. This ensures that a CodeBuild-provisioned runner can only ever register against repositories you explicitly allow, reducing both blast radius and the risk of accidental reuse across the organisation.
The permission model is also much cleaner than with PATs. At a minimum, the GitHub App needs:
- read access to code and metadata
- read and write access to administration
No organisation-wide permissions are required, and the short-lived installation access tokens generated by the App naturally fit the ephemeral runner lifecycle.
For personal projects, prototypes, or one-off experiments, a fine-grained or classic PAT can still be a perfectly acceptable and much simpler option. It avoids the additional setup overhead of a GitHub App and is often “good enough” when the scope and risk are limited.
AWS CodeBuild project & buildspec
From the CodeBuild project perspective, it really doesn’t get any simpler. The project does not need to be configured with a source repository at all — the buildspec can remain fully generic and work for any repository within your GitHub organisation.
The snippet below assumes the CodeBuild project is configured to use Amazon Linux running on Graviton-powered infrastructure.
# REQUIRED INPUT:
# - GITHUB_ORG (from IaC)
# - GITHUB_APP_ID (from IaC)
# - GITHUB_APP_INSTALLATION_ID (from IaC)
# - GITHUB_APP_PK_ASM_SECRET_ARN (from IaC)
# - GITHUB_REPO (from GHA workflow)
# - GITHUB_ACTIONS_RUNNER_NAME (from GHA workflow)
version: 0.2
env:
variables:
USER_HOME_DIR: "/home/runneruser"
secrets-manager:
GITHUB_APP_PK: "${GITHUB_APP_PK_ASM_SECRET_ARN}"
phases:
install:
commands:
- |
echo "> Creating runner user..."
useradd -m runneruser -d $USER_HOME_DIR
echo "runneruser ALL = NOPASSWD:/usr/bin/yum" >> /etc/sudoers # <- consider when need to install deps on the fly
echo "> Downloading the lastest runner installation package..."
cd $USER_HOME_DIR
RUNNER_VERSION=$(curl -s https://api.github.com/repos/actions/runner/tags | jq -r '.[0].name' | sed 's/^v//')
echo "Latest tag: $RUNNER_VERSION"
curl -Ls https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz -o actions-runner.tar.gz
mkdir actions-runner && tar xzf actions-runner.tar.gz -C actions-runner
chown -R runneruser:runneruser .
build:
commands:
- |
echo "> Generating GitHub App JWT (header + payload)..."
cd $USER_HOME_DIR/actions-runner
now=$(date +%s)
iat=$((${now} - 60)) # Issues 1 miute in the past
exp=$((${now} + 600)) # Expires 10 minutes in the future
b64enc() { openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n'; }
header_json='{
"typ":"JWT",
"alg":"RS256"
}'
# Header encode
header=$(echo -n "${header_json}" | b64enc)
payload_json="{
\"iat\":${iat},
\"exp\":${exp},
\"iss\":\"${GITHUB_APP_ID}\"
}"
# Payload encode
payload=$( echo -n "${payload_json}" | b64enc )
# Signature
header_payload="${header}"."${payload}"
signature=$(
openssl dgst -sha256 -sign <(echo -n "${GITHUB_APP_PK}") \
<(echo -n "${header_payload}") | b64enc
)
# Generate an installation token for the app
JWT="${header_payload}"."${signature}"
echo "> Requesting GitHub App installation access token..."
INSTALLATION_TOKEN=$(curl --request POST \
--url "https://api.github.com/app/installations/${GITHUB_APP_INSTALLATION_ID}/access_tokens" \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${JWT}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
| jq -r '.token'
)
echo "> Requesting ephemeral runner registration token..."
GITHUB_RUNNER_TOKEN=$(curl --request POST \
--url "https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/actions/runners/registration-token" \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${INSTALLATION_TOKEN}" | jq -r '.token'
)
echo "> Configuring GitHub Actions runner for ${GITHUB_ORG}/${GITHUB_REPO} ..."
su runneruser -c "./config.sh \
--url https://github.com/${GITHUB_ORG}/${GITHUB_REPO} \
--token ${GITHUB_RUNNER_TOKEN} \
--unattended --ephemeral \
--name ${GITHUB_ACTIONS_RUNNER_NAME} \
--labels self-hosted,${GITHUB_ACTIONS_RUNNER_NAME}"
echo "> Starting runner..."
su runneruser -c "./run.sh"
If you wish you can build a custom image and that way get rid of the install phase entirely.
GitHub Actions workflow
Below is a workflow snippet that completes the configuration and shows how everything fits together.
jobs:
start-cb-runner:
runs-on: ubuntu-latest
outputs:
runner_name: ${{ steps.start-cb-project.outputs.runner_name }}
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v5
with:
audience: sts.amazonaws.com
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE_ARN }}
role-session-name: GithubActionsSession
- name: Start CodeBuild project
id: start-cb-project
run: |
RUNNER_NAME="cb-runner-${GITHUB_RUN_ID}-${GITHUB_SHA::8}"
CODEBUILD_PROJECT_NAME="${{ env.CODEBUILD_PROJECT_NAME }}"
echo "runner_name=$RUNNER_NAME" >> $GITHUB_OUTPUT
PAYLOAD=$(jq -n \
--arg project "$CODEBUILD_PROJECT_NAME" \
--arg gh-repo "${{ github.event.repository.name }}" \
--arg gha-runner-name "$RUNNER_NAME" \
'{
projectName: $project,
environmentVariablesOverride: [
{name: "GITHUB_REPO", value: $gh-repo, type: "PLAINTEXT"},
{name: "GITHUB_ACTIONS_RUNNER_NAME", value: $gha-runner-name, type: "PLAINTEXT"}
]
}')
aws codebuild start-build --cli-input-json "$PAYLOAD" > /dev/null
run-tests-on-cb-runner:
needs: start-cb-runner
runs-on: [self-hosted, "${{ needs.start-cb-runner.outputs.runner_name }}"]
steps:
- uses: actions/checkout@v6
- name: Introduction message
run: |
echo "Testing ..."
From a performance perspective, bringing a runner online should take no more than ~30 seconds, after which it is ready to pick up the queued job.
One of the less obvious but critical elements is the RUNNER_NAME. In teams where multiple developers and testers run workflows in parallel, there is always a risk of workflows competing for runners. By generating a unique runner name per workflow run and passing it into the CodeBuild project, you guarantee that the runner you spin up is used exclusively for that specific execution and cannot be accidentally picked up by another job.
Finally, the CodeBuild project output should look similar to this:
Configuring and starting runner for sebolabs/my-repo ...
--------------------------------------------------------------------------------
| ____ _ _ _ _ _ _ _ _ |
| / ___(_) |_| | | |_ _| |__ / \ ___| |_(_) ___ _ __ ___ |
| | | _| | __| |_| | | | | '_ \ / _ \ / __| __| |/ _ \| '_ \/ __| |
| | |_| | | |_| _ | |_| | |_) | / ___ \ (__| |_| | (_) | | | \__ \ |
| \____|_|\__|_| |_|\__,_|_.__/ /_/ \_\___|\__|_|\___/|_| |_|___/ |
| |
| Self-hosted runner registration |
| |
--------------------------------------------------------------------------------
# Authentication
√ Connected to GitHub
# Runner Registration
√ Runner successfully added
# Runner settings
√ Settings Saved.
√ Connected to GitHub
Current runner version: '2.331.0’
2026-01-19 20:34:07Z: Listening for Jobs
2026-01-19 20:34:09Z: Running job: run-tests-on-cb-runner
2026-01-19 20:34:17Z: Job run-tests-on-cb-runner completed with result: Succeeded
√ Removed .credentials
√ Removed .runner
Runner listener exit with 0 return code, stop the service, no retry needed.
Exiting runner...
[Container] 2026/01/19 20:34:17.683704 Phase complete: BUILD State: SUCCEEDED
Wrap-up
Using AWS CodeBuild–powered, ephemeral GitHub Actions self-hosted runners without webhooks gives you precise, on-demand control over when and why you leave the GitHub-hosted runner pool. You retain full workflow flexibility, keep logs and execution visibility inside the GitHub Actions UI, and only incur additional infrastructure when a workflow genuinely requires network proximity to protected or private endpoints.
This model avoids over-coupling your CI/CD design to webhook-driven automation, reduces the risk of unintended runner usage, and scales cleanly even when many workflows are triggered in parallel. Treating CodeBuild runners as a specialised, opt-in execution environment — rather than a default — keeps both architecture and blast radius under control.
Historically, self-hosted GitHub Actions runners were effectively free from GitHub’s billing perspective — you only paid for the infrastructure you ran them on. That changes on March 1, 2026, when GitHub will introduce a $0.002 per-minute GitHub Actions cloud platform charge for self-hosted runner usage, with those minutes counting toward your plan.



Top comments (0)