Implementing Pipeline Cost Alerts for AWS CodeBuild
Unmonitored AWS CodeBuild compute spend compounds silently across high-frequency frontend and full-stack repositories — this guide shows how to wire up CloudWatch custom metrics, tiered AWS Budgets alerts, and SNS routing so your platform team gets actionable notifications before costs hit the invoice.
When to use this pattern
- Your organization runs CodeBuild for multiple frontend or full-stack projects and has no per-project cost visibility beyond the monthly bill.
- Build frequency is high enough (> 50 builds/day) that a single misconfigured compute tier can generate a material cost spike within hours.
- Your platform team owns a centralized observability stack and needs to route cost alerts to the same Slack channels or incident channels as infrastructure alarms.
Prerequisites
How the alert pipeline works
CodeBuild does not natively publish per-build cost metrics — it only exposes duration. The pattern bridges that gap by computing estimated cost inside buildspec.yml and pushing the value to a custom CloudWatch namespace. CloudWatch Alarms watch that namespace and fire into SNS when thresholds breach. AWS Budgets provides a separate, billing-data-backed safety net that catches accumulated spend the custom metrics might miss (for example, if the metric emission step itself fails).
The diagram below shows how the three components interact:
Complete working example
The block below is a single self-contained buildspec.yml that covers metric emission, SNS pre-notification, and environment tagging. Drop it into your CodeBuild project or merge the relevant phases into an existing spec.
# buildspec_cost_alerts.yml
# Requires: BUILD_START_TIME set in pre_build, ENVIRONMENT and SNS_TOPIC_ARN as env vars
version: 0.2
env:
variables:
COST_PER_MIN_SMALL: "0.010" # BUILD_GENERAL1_SMALL, us-east-1 — verify current pricing
COST_PER_MIN_MEDIUM: "0.020" # BUILD_GENERAL1_MEDIUM
COST_PER_MIN_LARGE: "0.040" # BUILD_GENERAL1_LARGE
METRIC_NAMESPACE: "CodeBuild/PipelineCosts"
phases:
pre_build:
commands:
# Capture wall-clock start time; CODEBUILD_BUILD_DURATION is not a native variable
- export BUILD_START_TIME=$(date +%s)
build:
commands:
- echo "Running build steps..."
# ... your actual build commands here
post_build:
commands:
- |
# ── Cost calculation ────────────────────────────────────────
BUILD_DURATION_SECS=$(($(date +%s) - BUILD_START_TIME))
BUILD_MINUTES=$(echo "scale=4; $BUILD_DURATION_SECS / 60" | bc)
# Select the rate for the active compute type
case "$CODEBUILD_BUILD_IMAGE" in
*large*) RATE="$COST_PER_MIN_LARGE" ;;
*medium*) RATE="$COST_PER_MIN_MEDIUM" ;;
*) RATE="$COST_PER_MIN_SMALL" ;;
esac
ESTIMATED_COST=$(echo "scale=6; $BUILD_MINUTES * $RATE" | bc)
# ── Emit custom metric ───────────────────────────────────────
# --unit None because USD is not a CloudWatch standard unit
aws cloudwatch put-metric-data \
--namespace "$METRIC_NAMESPACE" \
--metric-name "EstimatedBuildCostUSD" \
--value "$ESTIMATED_COST" \
--unit "None" \
--dimensions \
"BuildProject=$CODEBUILD_BUILD_ID" \
"Environment=$ENVIRONMENT" \
"Repository=$CODEBUILD_SOURCE_REPO_URL" \
|| echo "WARN: metric emission failed — check IAM cloudwatch:PutMetricData"
# ── Emit duration separately for latency dashboards ─────────
aws cloudwatch put-metric-data \
--namespace "$METRIC_NAMESPACE" \
--metric-name "BuildDurationSeconds" \
--value "$BUILD_DURATION_SECS" \
--unit "Seconds" \
--dimensions \
"BuildProject=$CODEBUILD_BUILD_ID" \
"Environment=$ENVIRONMENT" \
|| echo "WARN: duration metric emission failed"Step-by-step walkthrough
1. Emit cost metrics from post_build
CodeBuild exposes no native cost metric, so you calculate it at runtime. The pre_build phase captures BUILD_START_TIME as a Unix epoch; post_build subtracts it to get elapsed seconds. The case block selects the correct per-minute rate based on the build image string — this avoids hardcoding a single rate and breaking accuracy when projects use different compute tiers.
The two put-metric-data calls are intentionally separate so you can alarm on cost and on duration independently. Both emit with the same BuildProject, Environment, and Repository dimensions so CloudWatch Insights queries can join them.
2. Create the CloudWatch Alarm
Save the following JSON and deploy it once per environment. The 3-out-of-5 data-point window filters single-build outliers while still catching a sustained cost spike within 15 minutes.
{
"AlarmName": "CodeBuild-HighCost-Threshold-Production",
"ComparisonOperator": "GreaterThanThreshold",
"EvaluationPeriods": 5,
"DatapointsToAlarm": 3,
"Threshold": 150.0,
"MetricName": "EstimatedBuildCostUSD",
"Namespace": "CodeBuild/PipelineCosts",
"Dimensions": [
{ "Name": "Environment", "Value": "production" }
],
"Period": 300,
"Statistic": "Sum",
"TreatMissingData": "notBreaching",
"AlarmDescription": "Cumulative estimated CodeBuild cost exceeded $150 in a 25-minute window",
"AlarmActions": ["arn:aws:sns:us-east-1:123456789012:cicd-cost-alerts"],
"OKActions": ["arn:aws:sns:us-east-1:123456789012:cicd-cost-alerts"]
}Deploy and verify in one command:
aws cloudwatch put-metric-alarm \
--cli-input-json file://cloudwatch_alarm_cost.json \
&& aws cloudwatch describe-alarms \
--alarm-names "CodeBuild-HighCost-Threshold-Production" \
--query 'MetricAlarms[0].{State:StateValue,Threshold:Threshold}' \
--output table3. Configure AWS Budgets with tiered thresholds
AWS Budgets works from consolidated billing data — it catches spend the custom metric misses (for example if post_build exits before metric emission). Set three notification tiers so you get a warning before the hard limit.
# Create a monthly budget scoped to CodeBuild service
aws budgets create-budget \
--account-id "$AWS_ACCOUNT_ID" \
--budget '{
"BudgetName": "CodeBuild-Monthly",
"BudgetType": "COST",
"TimeUnit": "MONTHLY",
"BudgetLimit": { "Amount": "500", "Unit": "USD" },
"CostFilters": { "Service": ["AWS CodeBuild"] },
"CostTypes": {
"IncludeTax": false,
"IncludeSubscription": false,
"UseBlended": false
}
}' \
--notifications-with-subscribers '[
{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 50,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [{ "SubscriptionType": "SNS",
"Address": "arn:aws:sns:us-east-1:123456789012:cicd-cost-alerts" }]
},
{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 80,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [{ "SubscriptionType": "SNS",
"Address": "arn:aws:sns:us-east-1:123456789012:cicd-cost-alerts" }]
},
{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 100,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [{ "SubscriptionType": "SNS",
"Address": "arn:aws:sns:us-east-1:123456789012:cicd-cost-alerts" }]
}
]'4. Route SNS alerts to Slack via Lambda
A minimal Node.js Lambda formats the SNS payload into a Slack Block Kit message with the build ID, estimated cost delta, and a direct link to the CodeBuild build log.
// handler.js — Lambda triggered by SNS cicd-cost-alerts topic
const https = require("https");
exports.handler = async (event) => {
const record = event.Records[0].Sns;
const alarm = JSON.parse(record.Message);
const buildId = alarm.Trigger?.Dimensions?.find(d => d.name === "BuildProject")?.value ?? "unknown";
const webhookUrl = process.env.SLACK_WEBHOOK_URL; // set as Lambda env var
const payload = JSON.stringify({
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*CodeBuild cost alert*: \`${alarm.AlarmName}\`\n` +
`State: *${alarm.NewStateValue}*\n` +
`Build: \`${buildId}\`\n` +
`Reason: ${alarm.NewStateReason}`
}
}
]
});
// Post to Slack incoming webhook
const url = new URL(webhookUrl);
await new Promise((resolve, reject) => {
const req = https.request(
{ hostname: url.hostname, path: url.pathname + url.search, method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } },
(res) => { res.on("data", () => {}); res.on("end", resolve); }
);
req.on("error", reject);
req.write(payload);
req.end();
});
};5. Deploy via IaC and validate parity
Wrap the CloudWatch Alarm and Budget in a Terraform module (or a CloudFormation stack) so staging and production environments stay in version-controlled parity. Before promoting to production, run a dry-run validation:
# Validate alarm state — INSUFFICIENT_DATA is expected before any builds have run
ALARM_STATE=$(aws cloudwatch describe-alarms \
--alarm-names "CodeBuild-HighCost-Threshold-Production" \
--query 'MetricAlarms[0].StateValue' \
--output text)
case "$ALARM_STATE" in
INSUFFICIENT_DATA)
echo "OK: alarm exists, awaiting first data points" ;;
OK)
echo "OK: alarm active and below threshold" ;;
ALARM)
echo "WARN: alarm firing during validation — check thresholds or rollback"
aws cloudwatch delete-alarms --alarm-names "CodeBuild-HighCost-Threshold-Production"
exit 1 ;;
esac
# Confirm the Budget was created
aws budgets describe-budgets \
--account-id "$AWS_ACCOUNT_ID" \
--query 'Budgets[?BudgetName==`CodeBuild-Monthly`].{Name:BudgetName,Limit:BudgetLimit.Amount}' \
--output tableVerification
After running one or more builds, confirm the metric is flowing and the alarm is evaluating:
# 1. Check that metrics have arrived in the namespace
aws cloudwatch list-metrics \
--namespace "CodeBuild/PipelineCosts" \
--query 'Metrics[].{Name:MetricName,Dimensions:Dimensions}' \
--output table
# Expected: rows for EstimatedBuildCostUSD and BuildDurationSeconds
# 2. Pull the last 30 minutes of cost data
aws cloudwatch get-metric-statistics \
--namespace "CodeBuild/PipelineCosts" \
--metric-name "EstimatedBuildCostUSD" \
--dimensions Name=Environment,Value=production \
--start-time "$(date -u -d '-30 minutes' +%Y-%m-%dT%H:%M:%SZ)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--period 300 \
--statistics Sum \
--output table
# 3. Confirm the alarm is not in INSUFFICIENT_DATA after 10+ builds
aws cloudwatch describe-alarms \
--alarm-names "CodeBuild-HighCost-Threshold-Production" \
--query 'MetricAlarms[0].StateValue' \
--output textExpected output for list-metrics: at least one row per build environment. Expected output for get-metric-statistics: a Sum column with non-zero values. Expected alarm state: OK (or ALARM if you’ve intentionally triggered a test breach).
Common pitfalls
Silent metric drops despite successful builds. The CodeBuild service role is missing cloudwatch:PutMetricData. The || echo "WARN: ..." in the buildspec surfaces the failure in build logs but does not fail the build — so cost data silently disappears. Attach a scoped policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricData"
],
"Resource": "*",
"Condition": {
"StringEquals": { "cloudwatch:namespace": "CodeBuild/PipelineCosts" }
}
}
]
}False-positive alarms during cache-cold builds. Static thresholds do not account for the first build after a cache eviction, where dependency installation adds several minutes. Use CloudWatch Anomaly Detection bands instead of a fixed threshold, or maintain separate alarm configurations for the cache-cold environment dimension that you tag explicitly in buildspec.yml.
CODEBUILD_BUILD_DURATION not resolving. That variable does not exist natively in CodeBuild. Always derive duration by subtracting BUILD_START_TIME (set in pre_build) from $(date +%s) in post_build. If pre_build is skipped due to a phase failure, the subtraction will produce a negative number — guard with [[ $BUILD_DURATION_SECS -lt 0 ]] && BUILD_DURATION_SECS=0.
← Back to Tracking CI/CD Compute Costs for Platform Teams
Related
- Tracking CI/CD Compute Costs for Platform Teams — the parent page covering cost attribution strategies, tagging standards, and chargeback models for platform engineering teams.
- CI/CD Pipeline Architecture Fundamentals — core pipeline design principles that govern how metric emission fits into multi-stage pipeline topology without adding synchronous latency.
- Optimizing Pipeline Concurrency and Queue Limits — controlling concurrent build concurrency directly limits the peak cost exposure that your alerts need to cover.
- Reducing GitHub Actions Minutes with Self-Hosted Runners — a sibling pattern for cutting managed-compute spend that complements cost alerting with proactive capacity management.