A team of fifteen engineers pushes to a monorepo. CI runs on every push, every PR, every merge — a full pipeline that builds, tests, and deploys ephemeral preview environments. The CodeBuild line on the AWS bill grew steadily for two quarters and is now uncomfortable. The finance lead asks for a 40% reduction without slowing engineers down. Six tactical changes get there, and most of them are configuration, not engineering work.
What’s happening
CodeBuild is billed per build-minute, per compute type. The price difference between compute tiers is roughly linear in vCPU and memory, but build duration is not linear in compute size — a build that runs 10 minutes on BUILD_GENERAL1_SMALL does not run 5 minutes on BUILD_GENERAL1_MEDIUM. It might run 7 minutes. Or 4. Or 10. The shape of your build dictates the right tier.
The cost levers, in order of impact for the typical team:
-
Compute type sizing. Wrong-sized compute is the biggest waste. Builds bound on a single CPU step (a Webpack bundle, a
go buildof a large module) finish faster and cheaper on a larger tier because the per-minute rate increase is less than the duration decrease. Builds that are I/O bound (npm install, Docker layer pull) finish at the same speed regardless of tier — pay for the smallest. -
Caching. A build that takes 6 minutes cold and 90 seconds warm costs 6.7x more per cold run. Without caching turned on, every build is cold. See the separate KB on Docker layer caching for the full pattern.
-
Path filters. A monorepo with
apps/web,apps/api,apps/workershouldn’t rebuild the worker on every change to the web app. Path filters in the CodePipeline source action skip builds when the changed files don’t match the project’s scope. -
Batch builds. Multiple parallel builds in one CodeBuild “batch build” share the source download. Useful when you build N variants (architectures, Node versions, environments) of the same source.
-
Conditional CI on PR labels. Not every PR needs the full pipeline. Run integration tests only when the PR has the
needs-integrationlabel or the path filter matches infrastructure files. Cuts the deepest pipeline runs to ~20% of PRs. -
Tear down ephemeral envs aggressively. Preview environments per PR are great until 50 of them are running. Each one has its own ECS service, RDS, Redis. Auto-teardown after the PR closes, after 48h of inactivity, or on a nightly cron.
How to verify
Find the cost drivers first. The Cost Explorer query that ranks projects by build-minute spend over the last 30 days:
aws ce get-cost-and-usage \
--time-period Start=$(date -u -v-30d +%Y-%m-%d),End=$(date -u +%Y-%m-%d) \
--granularity DAILY \
--metrics UnblendedCost \
--filter '{"Dimensions":{"Key":"SERVICE","Values":["AWS CodeBuild"]}}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE \
--query 'ResultsByTime[].Groups[].{Type:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
--output table
The output shows usage by tier — USE1-Build-Min:Linux:gp1.small, USE1-Build-Min:Linux:gp1.medium, etc. If the medium line dominates, the SMALL tier is probably under-provisioned and pushing builds to MEDIUM unnecessarily. If gp1.large is on the list and you don’t remember enabling it, somebody’s update-project call needs investigation.
Per-project breakdown over the same window — CodeBuild emits a CloudWatch metric for build duration. The query that finds projects burning the most build-minutes:
aws cloudwatch get-metric-statistics \
--namespace AWS/CodeBuild \
--metric-name Duration \
--dimensions Name=ProjectName,Value=web-api \
--start-time $(date -u -v-30d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 86400 \
--statistics Sum,SampleCount \
--query 'Datapoints[].{Date:Timestamp,TotalSec:Sum,Runs:SampleCount}' \
--output table
Run that across every project (loop driven by aws codebuild list-projects) and you have a table of total compute-seconds per project per day. The top of the table is where the savings are.
The metric to graph alongside cost is Duration percentiles. A project whose Duration p95 is 2x its p50 has a long-tail problem — usually a flaky test that retries or a cache miss. Fixing the long tail saves more than tuning the median:
aws cloudwatch get-metric-statistics \
--namespace AWS/CodeBuild \
--metric-name Duration \
--dimensions Name=ProjectName,Value=web-api \
--start-time $(date -u -v-7d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--extended-statistics p50,p95,p99 \
--query 'Datapoints[].{Time:Timestamp,p50:ExtendedStatistics.p50,p95:ExtendedStatistics.p95,p99:ExtendedStatistics.p99}' \
--output table
If p99 is 3x p50, you have flaky retries. If p95 is 1.3x p50, you have a healthy build.
Compute-type sizing is empirical. Take a representative build, run it three times on each tier you’re considering, average the duration, multiply by the per-minute rate. The “cost per build” curve usually has a clear minimum. For a Node app with a big install step, MEDIUM is often the winner; for a Go binary, SMALL is often plenty.
Path filters in CodePipeline’s source action — for a GitHub source:
aws codepipeline update-pipeline \
--cli-input-json '{
"pipeline": {
"name": "web-monorepo",
"stages": [{
"name": "Source",
"actions": [{
"name": "GitHubSource",
"configuration": {
"ConnectionArn": "arn:aws:codestar-connections:...",
"FullRepositoryId": "acme/monorepo",
"BranchName": "main",
"DetectChanges": "true"
},
"filterStateChange": {
"includePaths": ["apps/web/**", "packages/shared/**"]
}
}]
}]
}
}'
The filterStateChange.includePaths is the filter — pushes that touch only apps/api/** won’t trigger the web pipeline. Each app’s pipeline has its own include path. The monorepo gets N small builds instead of one big one, and N-1 of them are no-ops when a typical PR touches one app.
The fix
The playbook that cuts CodeBuild spend without slowing the team:
-
Audit current spend by USAGE_TYPE and project. The Cost Explorer query above plus the per-project Duration metric give you the leaderboard. Optimize the top three projects; ignore the long tail.
-
Right-size compute by measurement, not intuition. Pick the project at the top of the leaderboard. Time a representative build on each compute tier. Compute cost = duration × per-minute rate. The minimum is the right tier.
-
Turn on LOCAL caching everywhere.
aws codebuild update-project --cache type=LOCAL,modes=LOCAL_DOCKER_LAYER_CACHE,LOCAL_SOURCE_CACHEis a one-liner per project. Free. Catches the warm-host case. -
Set path filters on monorepo pipelines. Every pipeline gets
filterStateChange.includePathsscoped to the directories it actually builds. A naïve monorepo runs 3-10x more builds than it needs to. -
Make integration tests opt-in for PRs. A label-based filter (
needs-integration) on the PR-triggered pipeline cuts the integration test run count by 70-80% without losing coverage on infra changes (those changes are auto-labeled). -
TTL on ephemeral environments. Every preview env gets a
Expirestag set to PR_creation_time + 7d. A daily Lambda lists ECS services withExpires < now()and deletes them. The savings are dramatic — the long tail of preview envs left over from closed PRs is usually the silent biggest line item. -
Batch builds for fan-out workloads. A build that produces images for amd64 + arm64 should be a CodeBuild batch with two parallel build groups, not two separate CodePipeline runs. Batch shares source download, which is real time for big repos.
-
Set a cost alarm. A CloudWatch billing alarm at 120% of last month’s CodeBuild spend catches the runaway-build case. The most common runaway is a developer who accidentally pushes a commit that triggers a build loop (build pushes a commit, which triggers a build, etc.). The alarm catches it within an hour.
The pattern that runs CI for a 15-engineer team without surprise invoices, in one paragraph: SMALL compute for typecheck/lint/unit; MEDIUM for the Docker image build; LOCAL cache on every project; path filters on the monorepo; integration tests label-gated; preview envs auto-deleted after 7 days. The team doesn’t notice the changes. The bill drops.
One pattern worth a callout because it bites a lot of teams: branch-name-driven cache pollution. A pipeline that pushes :cache-${BRANCH_NAME} ECR tags accumulates dead cache tags as branches get deleted. After a year, the ECR repo has 500 dead :cache-* tags and the storage bill grows quietly. Lifecycle policy on ECR — keep cache tags only for branches alive in the last 30 days — closes the loop:
aws ecr put-lifecycle-policy \
--repository-name web \
--lifecycle-policy-text '{
"rules": [{
"rulePriority": 1,
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["cache-"],
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 30
},
"action": { "type": "expire" }
}]
}'
CodeBuild costs are mechanical to control. The leverage is in measurement first, then the cheapest tactical lever that the measurement points at. Engineering on the buildspec itself is usually the last lever to pull, not the first.
Stack Harbor runs CodeBuild + CodePipeline at production scale as part of Managed AWS onboarding and ongoing Managed Cloud Ops. If your CodeBuild line is climbing faster than your team is shipping, start with an infrastructure consult.