PipelineOps

Setting Up AWS CodeBuild as a GitHub Actions Runner: No More Self-Managed EC2

WORKFLOW_JOB_QUEUED — not knowing this event type cost me an entire day setting up CodeBuild.

TL;DR: AWS CodeBuild can run as a GitHub Actions self-hosted runner. No EC2 to manage, ephemeral by default, and AWS resources are accessible via IAM roles — no credentials in GitHub Secrets. The catch: the Webhook event type must be WORKFLOW_JOB_QUEUED, and the IAM trust policy must point to codebuild.amazonaws.com. Get either wrong and nothing works. This post covers both gotchas plus a full setup walkthrough including VPC.


What I Was Trying to Do

If you've run self-hosted runners on EC2, you know the operational cost. Disk monitoring, health checks, post-job cleanup, state contamination — I wrote about those pitfalls in a previous post. Getting it running is easy; keeping it running reliably means babysitting infrastructure yourself.

Our team had a multi-service setup where Docker image builds took 10–15 minutes per run. GitHub-hosted runners (2-core / 7 GB) were too slow, but our persistent EC2 runners were already showing signs of operational fatigue.

That's when I tried using AWS CodeBuild as a GitHub Actions runner — a feature AWS added in 2023. You register a CodeBuild project as a self-hosted runner. When a job arrives, CodeBuild spins up an environment, runs it, then tears it down. Ephemeral by design.

We also needed access to private VPC resources — an internal container registry and internal APIs. CodeBuild runs inside a VPC, so that requirement was covered too.


What Went Wrong (and Why)

Wrong Webhook event type

When setting up a CodeBuild Webhook, it's tempting to reach for PUSH or PULL_REQUEST — those are the event types you're used to from regular GitHub integrations. But when you're using CodeBuild as a GitHub Actions runner, the correct event type is WORKFLOW_JOB_QUEUED.

This event fires when GitHub queues a job. CodeBuild receives it, spins up a runner, and picks up the job. With PUSH or PULL_REQUEST, CodeBuild does trigger a build — but in standalone CI mode, not as a GitHub Actions runner. The result: your workflow job sits at Waiting for a runner... indefinitely.

The documentation is clear enough once you find it. The problem is that searching "GitHub Actions CodeBuild integration" surfaces a lot of older posts describing the standalone CI approach. That's a different feature.

IAM trust policy confusion

I got tripped up on the IAM role's trust policy. I was thinking of GitHub Actions OIDC and wrote sts:AssumeRoleWithWebIdentity — which is wrong here.

The trust policy for a CodeBuild runner role is straightforward: allow codebuild.amazonaws.com to assume the role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codebuild.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Get this wrong and CodeBuild fails to assume the role and won't start. The error message is cryptic enough that I spent time chasing other causes first.


The Fix — Step by Step

Step 1: Connect GitHub to CodeBuild

In the AWS Console, go to CodeBuild → Source connections and connect via GitHub App or Personal Access Token (PAT). Either way, you'll need to involve a GitHub Organization Admin — there's no getting around that.

GitHub App (CodeConnections): One connection maps 1:1 to one GitHub Organization. If you need to connect multiple organizations, create a separate connection for each.

Fine-grained PAT: Classic PATs are deprecated — use fine-grained PATs. For runner usage, the required permissions are: Contents (Read-only) / Commit statuses (Read and write) / Webhooks (Read and write) / Administration (Read and write). (Source: AWS docs, checked 2026-04-17.) For Organization repositories, set the resource owner to the Organization — not your personal account — when generating the token.

Step 2: Create the IAM role

The IAM role needs two policies.

① Trust Policy: Allows CodeBuild to assume the role.

trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codebuild.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

② Permissions Policy: Scoped to what your builds actually need. Start minimal and add as required. The example below covers ECR push/pull and CloudWatch Logs.

permissions-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:us-west-2:*:*"
    }
  ]
}

Step 3: Create the CodeBuild project

Three settings matter most.

Source: Point to your GitHub repository.

Environment: Choose compute type and image. For project creation, aws/codebuild/standard:7.0 (Ubuntu 22.04) is the current image URI. Note that when overriding the image via runs-on labels, you use the short identifier — like ubuntu-7.0 — not the full URI (more on this later).

Project name: This must exactly match the label in runs-on. Renaming later means updating every workflow file. Agree on a naming convention — something like <team>-<purpose>-runner — before creating.

create-project.sh
aws codebuild create-project \
  --name "my-github-runner" \
  --source '{"type":"GITHUB","location":"https://github.com/my-org/my-repo"}' \
  --artifacts '{"type":"NO_ARTIFACTS"}' \
  --environment '{
    "type":"LINUX_CONTAINER",
    "image":"aws/codebuild/standard:7.0",
    "computeType":"BUILD_GENERAL1_MEDIUM",
    "privilegedMode":true
  }' \
  --service-role "arn:aws:iam::123456789012:role/codebuild-github-runner-role" \
  --region us-west-2

privilegedMode: true is required for Docker-in-Docker (building container images).

Step 4: Set up the Webhook

This is the critical step. The event type must be WORKFLOW_JOB_QUEUED.

create-webhook.sh
aws codebuild create-webhook \
  --project-name "my-github-runner" \
  --filter-groups "[[{\"type\":\"EVENT\",\"pattern\":\"WORKFLOW_JOB_QUEUED\"}]]" \
  --region us-west-2

To restrict the runner to jobs from a specific workflow, add a WORKFLOW_NAME filter:

create-webhook-filtered.sh
aws codebuild create-webhook \
  --project-name "my-github-runner" \
  --filter-groups "[[
    {\"type\":\"EVENT\",\"pattern\":\"WORKFLOW_JOB_QUEUED\"},
    {\"type\":\"WORKFLOW_NAME\",\"pattern\":\"CI\"}
  ]]" \
  --region us-west-2

Step 5: Update your workflow

Change runs-on to the CodeBuild label format. The combination of project name and ${{ github.run_id }}-${{ github.run_attempt }} is what CodeBuild uses to match the job to the runner.

.github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  build:
    runs-on:
      - codebuild-my-github-runner-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@v6
 
      - name: Build Docker image
        run: |
          aws ecr get-login-password --region us-west-2 | \
            docker login --username AWS --password-stdin \
            123456789012.dkr.ecr.us-west-2.amazonaws.com
          docker build -t my-app .
          docker push 123456789012.dkr.ecr.us-west-2.amazonaws.com/my-app:${{ github.sha }}

The IAM role is attached to the CodeBuild project, so there are no AWS credentials to store in GitHub Secrets. The docker push to ECR works purely through the IAM role.

Step 6: Run inside a VPC

If you need access to private VPC resources — RDS, internal APIs, a private container registry — add VPC configuration to the CodeBuild project.

update-vpc.sh
aws codebuild update-project \
  --name "my-github-runner" \
  --vpc-config '{
    "vpcId": "vpc-xxxxxxxx",
    "subnets": ["subnet-xxxxxxxx"],
    "securityGroupIds": ["sg-xxxxxxxx"]
  }' \
  --region us-west-2

Running in a private subnet requires a NAT Gateway for outbound internet access (GitHub API, ECR, npm, etc.). For fully air-gapped environments, configure VPC endpoints for each AWS service instead.


What I'd Do Differently

Settle on a project naming convention first. The runs-on label must exactly match the project name. Renaming means touching every workflow file that references it. Agree on <team>-<purpose>-runner or similar before creating anything.

Start with BUILD_GENERAL1_MEDIUM. I started with BUILD_GENERAL1_SMALL (2 vCPU / 4 GB) and hit OOM during Docker builds. Switching to BUILD_GENERAL1_MEDIUM (4 vCPU / 8 GB) fixed it. Measure actual build times, then tune — don't guess.

Watch the one-hour runner token limit. CodeBuild's runner token expires one hour after it's issued. If your INSTALL or PRE_BUILD phases install a lot of packages, you may run out of time before the job finishes. Use S3 caching for dependencies, or bake required tools into a custom image.


Key Takeaways

  • WORKFLOW_JOB_QUEUED — this is the only correct Webhook event type. PUSH and PULL_REQUEST won't work for runner mode
  • IAM trust policy principal: codebuild.amazonaws.com — don't confuse it with the OIDC setup
  • runs-on label format: codebuild-<project-name>-${{ github.run_id }}-${{ github.run_attempt }}
  • No AWS credentials in GitHub Secrets — the IAM role handles everything
  • VPC access is one config block away — private subnet + NAT Gateway covers most cases
  • Ephemeral by default — state contamination and disk management are AWS's problem now



FAQ

Q: What do I need to use AWS CodeBuild as a GitHub Actions runner?

A: Four things: a CodeBuild project, a GitHub connection (GitHub App or PAT), an IAM role that trusts codebuild.amazonaws.com, and a Webhook configured for the WORKFLOW_JOB_QUEUED event. On the workflow side, change runs-on to codebuild-<project-name>-${{ github.run_id }}-${{ github.run_attempt }} and you're done.

Q: How does the cost compare to GitHub-hosted runners?

A: It depends heavily on job duration, frequency, and instance size. Run the numbers against your actual build times — check the GitHub Actions pricing page and AWS CodeBuild pricing page for current rates. Heavy jobs — Docker builds, large test suites — tend to benefit from the larger compute options CodeBuild offers, cutting wall-clock time enough to offset the per-minute rate.

Q: Should I use EC2 self-hosted runners or CodeBuild runners?

A: Neither is universally better — it depends on your situation.

CodeBuild runners are a better fit when:

  • You want to stop managing infrastructure (disk monitoring, health checks, AMI updates)
  • You want ephemeral builds out of the box
  • Your workload is bursty and pay-per-use billing makes sense
  • You need VPC access but don't want an EC2 instance running 24/7

EC2 self-hosted runners are a better fit when:

  • You have hardware requirements CodeBuild can't meet (specific GPU models, very large memory)
  • You need to bake custom tooling or licensed software into an AMI for fast cold starts
  • High-frequency, high-volume jobs make always-on EC2 more cost-efficient than per-minute billing

Either way, ephemeral per-job environments are the baseline worth aiming for. For the EC2 ephemeral approach, see this post.

Q: Can I build for ARM (Graviton)?

A: Yes. Add image:arm-3.0 and instance-size: as indented continuation lines under the first runs-on entry — not as separate list items.

runs-on:
  - codebuild-my-runner-${{ github.run_id }}-${{ github.run_attempt }}
    image:arm-3.0
    instance-size:small

For multi-architecture Docker images, the typical setup is two separate CodeBuild projects — one for x86_64, one for arm64 — running in parallel.

Q: Where do I find build logs for CodeBuild runners?

A: CloudWatch Logs receives them automatically. You can also browse them in the CodeBuild console under Build History. GitHub Actions' UI shows job logs too, but the CodeBuild side includes infrastructure-level detail like environment startup time.


Drawn from SRE experience across multiple organizations. Any details that could identify specific companies or individuals have been removed or generalized.