DEV Community

Sebastian Mincewicz for AWS Community Builders

Posted on • Originally published at Medium

AWS CodeBuild-powered GitHub Actions self-hosted runners — without webhooks

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.

post image

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:

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.

Flow HLD

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"
Enter fullscreen mode Exit fullscreen mode

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 ..."
Enter fullscreen mode Exit fullscreen mode

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.

GHA view

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
Enter fullscreen mode Exit fullscreen mode

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)