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.
{
"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.
{
"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.
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-2privilegedMode: 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.
aws codebuild create-webhook \
--project-name "my-github-runner" \
--filter-groups "[[{\"type\":\"EVENT\",\"pattern\":\"WORKFLOW_JOB_QUEUED\"}]]" \
--region us-west-2To restrict the runner to jobs from a specific workflow, add a WORKFLOW_NAME filter:
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-2Step 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.
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.
aws codebuild update-project \
--name "my-github-runner" \
--vpc-config '{
"vpcId": "vpc-xxxxxxxx",
"subnets": ["subnet-xxxxxxxx"],
"securityGroupIds": ["sg-xxxxxxxx"]
}' \
--region us-west-2Running 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.PUSHandPULL_REQUESTwon't work for runner mode- IAM trust policy principal:
codebuild.amazonaws.com— don't confuse it with the OIDC setup runs-onlabel 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:smallFor 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.
Related Articles
- GitHub Actions Self-Hosted Runners on AWS EC2: What No One Tells YouI put a self-hosted runner on EC2 and it died at 2am. Here's what broke, why non-ephemeral runners are a trap, and the step-by-step path to a production-ready setup.
- Why Your CI Pipeline Is Slow (And How to Fix It)CI pipelines slow down for four reasons: missing cache, sequential jobs, no path filtering, and broken Docker layer cache. I diagnosed a 32-minute pipeline and cut it down to about 15 minutes.
- Docker Multi-Stage Builds: Cut Image Size by 80%My Spring Boot Docker image hit 1.2 GB. CI took 12 minutes per run and Trivy flagged 140 vulnerabilities. Multi-stage builds brought it down to 245 MB — here's exactly what I changed.