Infrastructure Cost Estimation Before Deployment
Estimate infrastructure costs before deployment with Infracost, CI/CD integration, budget guardrails, and custom Node.js cost tools
Infrastructure Cost Estimation Before Deployment
Overview
Nothing kills a project faster than an unexpected cloud bill. Infrastructure cost estimation before deployment gives engineering teams visibility into the financial impact of every infrastructure change before it hits production. This article walks through practical tooling, CI/CD integration patterns, and custom Node.js solutions that put cost guardrails directly into your development workflow.
Prerequisites
- Terraform installed (v1.0+) with basic HCL knowledge
- Node.js v16+ for custom tooling examples
- Git and a CI/CD platform (GitHub Actions, GitLab CI, or similar)
- An AWS, Azure, or GCP account with infrastructure already defined in code
- Basic understanding of infrastructure-as-code concepts
- Infracost CLI (we will install it during the walkthrough)
Why Estimate Before Deploying
Most teams discover cost problems after the bill arrives. That is too late. A single engineer spinning up an oversized RDS instance or forgetting to set a lifecycle policy on an S3 bucket can burn through thousands of dollars before anyone notices. The feedback loop between "deploy" and "invoice" is typically 30 days. That is an eternity in software development.
Estimating costs at the pull request stage does three things. First, it makes cost a first-class concern in code review, just like security and correctness. Second, it creates an audit trail connecting infrastructure changes to their financial impact. Third, it allows you to set hard budget limits that block deployments automatically.
The FinOps Foundation calls this "shift left on cost." The principle is simple: catch cost problems as early as possible in the development lifecycle, ideally before code is merged.
Infracost CLI Setup and Usage
Infracost is the most mature open-source tool for infrastructure cost estimation. It parses Terraform plans and maps resources to cloud provider pricing data.
Installation
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/infracost/infracost/master/scripts/install.sh | sh
# Windows (via Chocolatey)
choco install infracost
# Verify installation
infracost --version
Authentication
Infracost requires a free API key for pricing data lookups:
infracost auth login
This opens a browser to register and stores the key in ~/.config/infracost/credentials.yml.
Basic Usage
The simplest workflow runs against a Terraform directory:
# Generate a Terraform plan
cd /path/to/terraform
terraform init
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json
# Run Infracost against the plan
infracost breakdown --path plan.json
The output shows a resource-by-resource cost breakdown:
Name Monthly Qty Unit Monthly Cost
aws_instance.web_server
├─ Instance usage (Linux/UNIX, on-demand, t3.large)
│ 730 hours $60.74
├─ root_block_device
│ └─ Storage (general purpose SSD, gp3) 50 GB $4.00
└─ ebs_block_device[0]
└─ Storage (general purpose SSD, gp3) 200 GB $16.00
aws_db_instance.primary
├─ Database instance (on-demand, db.r5.xlarge) 730 hours $365.00
└─ Storage (general purpose SSD, gp2) 100 GB $11.50
OVERALL TOTAL $457.24
You can also point Infracost directly at a Terraform directory without generating a plan first:
infracost breakdown --path /path/to/terraform
This is convenient for quick checks, but using a plan file is more accurate because it reflects the actual resources that Terraform will create.
Cost Estimation in CI/CD Pipelines
Running Infracost locally is useful for development, but the real value comes from automating it in CI/CD. Every pull request that touches infrastructure code should include a cost estimate.
GitHub Actions Integration
name: Infrastructure Cost Check
on:
pull_request:
paths:
- 'terraform/**'
jobs:
infracost:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: '${{ github.event.pull_request.base.ref }}'
- name: Setup Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate base cost
run: |
infracost breakdown --path=terraform \
--format=json \
--out-file=/tmp/infracost-base.json
- name: Checkout PR branch
uses: actions/checkout@v4
- name: Generate PR cost diff
run: |
infracost diff --path=terraform \
--compare-to=/tmp/infracost-base.json \
--format=json \
--out-file=/tmp/infracost-diff.json
- name: Post PR comment
run: |
infracost comment github \
--path=/tmp/infracost-diff.json \
--repo=$GITHUB_REPOSITORY \
--github-token=${{ secrets.GITHUB_TOKEN }} \
--pull-request=${{ github.event.pull_request.number }} \
--behavior=update
GitLab CI Integration
infracost:
image: infracost/infracost:ci-0.10
stage: validate
variables:
INFRACOST_API_KEY: $INFRACOST_API_KEY
script:
- git clone $CI_REPOSITORY_URL --branch=$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --single-branch /tmp/base
- infracost breakdown --path=/tmp/base/terraform --format=json --out-file=/tmp/base.json
- infracost diff --path=terraform --compare-to=/tmp/base.json --format=json --out-file=/tmp/diff.json
- infracost comment gitlab --path=/tmp/diff.json --repo=$CI_PROJECT_PATH --merge-request=$CI_MERGE_REQUEST_IID --gitlab-token=$GITLAB_TOKEN --behavior=update
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- terraform/**/*
PR Cost Comments with Infracost
When Infracost posts a comment on a pull request, reviewers see exactly what the change will cost. A typical comment looks like this:
## 💰 Infracost estimate
Monthly cost will increase by $182.50 ↑
| Project | Previous | New | Diff |
|---------|----------|---------|-----------|
| prod | $457.24 | $639.74 | +$182.50 |
### Changed Resources
| Resource | Previous | New | Diff |
|-----------------------|-----------|-----------|-----------|
| aws_instance.api | $60.74 | $121.47 | +$60.74 |
| aws_rds_cluster.main | $0.00 | $121.76 | +$121.76 |
This makes cost a visible, reviewable part of every infrastructure change. Engineers cannot claim they did not know a change was expensive when the number is right there in the PR.
Configuring Comment Behavior
The --behavior flag controls how comments are managed:
update— Updates the existing Infracost comment (recommended for most teams)hide-and-new— Hides old comments and creates a new onedelete-and-new— Deletes old comments and creates a new onenew— Always creates a new comment
For most workflows, update is the right choice. It keeps the PR thread clean and always shows the latest estimate.
Custom Cost Policies
Infracost supports a policy engine that lets you define custom rules. These are written as Open Policy Agent (OPA) Rego files:
# policy.rego
package infracost
deny[msg] {
r := input.projects[_].breakdown.resources[_]
r.name == "aws_instance"
to_number(r.monthlyCost) > 500
msg := sprintf("Instance %s costs $%s/month, exceeds $500 limit", [r.metadata.filename, r.monthlyCost])
}
deny[msg] {
maxDiff := 1000
to_number(input.diffTotalMonthlyCost) > maxDiff
msg := sprintf("Total monthly cost increase $%s exceeds $%d threshold", [input.diffTotalMonthlyCost, maxDiff])
}
Run the policy check:
infracost breakdown --path=terraform --format=json | infracost policy check --policy-path=policy.rego
If any deny rule fires, the command exits with a non-zero code, failing the CI pipeline.
Terraform Plan Cost Analysis
For more detailed analysis, you can parse the Terraform plan JSON directly. This is useful when you need cost data in a format Infracost does not natively support.
# Generate detailed plan JSON
terraform plan -out=plan.binary
terraform show -json plan.binary > plan.json
# Extract resource changes
cat plan.json | jq '.resource_changes[] | {type: .type, name: .name, action: .change.actions[0]}'
The plan JSON tells you exactly which resources are being created, modified, or destroyed. Combined with Infracost output, you get a complete picture of both the infrastructure change and its cost.
Multi-Environment Plans
When managing staging and production environments, run separate cost estimates:
# Staging
infracost breakdown --path=terraform/environments/staging --format=json --out-file=staging.json
# Production
infracost breakdown --path=terraform/environments/production --format=json --out-file=production.json
# Compare
infracost output --path="staging.json" --path="production.json" --format=table
Comparing Costs Across Environments
A common problem is cost drift between environments. Staging should be cheaper than production, but without monitoring, they tend to converge. Build a comparison into your workflow:
#!/bin/bash
# compare-environments.sh
ENVS=("dev" "staging" "production")
for env in "${ENVS[@]}"; do
infracost breakdown \
--path="terraform/environments/$env" \
--format=json \
--out-file="/tmp/cost-$env.json"
done
infracost output \
--path="/tmp/cost-dev.json" \
--path="/tmp/cost-staging.json" \
--path="/tmp/cost-production.json" \
--format=table \
--show-skipped
This gives you a side-by-side view of what each environment costs. If staging is within 20% of production cost, something is probably wrong.
Cost Estimation for CDK and CloudFormation
Infracost primarily targets Terraform, but AWS CDK and CloudFormation users have options too.
AWS CDK
Synthesize your CDK app to CloudFormation, then convert:
# Synthesize CDK to CloudFormation
cdk synth --output=cdk.out
# Use Infracost with the generated template
infracost breakdown --path=cdk.out/MyStack.template.json --format=json
Note that Infracost's CloudFormation support is not as comprehensive as Terraform support. Some resource types may show as "unsupported" and will not have cost estimates.
AWS Pricing API Directly
For CloudFormation-native workflows, query the AWS Pricing API:
aws pricing get-products \
--service-code AmazonEC2 \
--filters "Type=TERM_MATCH,Field=instanceType,Value=t3.large" \
"Type=TERM_MATCH,Field=location,Value=US East (N. Virginia)" \
"Type=TERM_MATCH,Field=operatingSystem,Value=Linux" \
--region us-east-1
This returns raw pricing data you can parse programmatically.
Building a Custom Cost Estimator with Node.js
When you need cost estimation logic tailored to your organization, building a custom tool gives you full control. Here is a Node.js cost estimator that parses Terraform plan JSON and calculates costs using a local pricing database.
var fs = require("fs");
var path = require("path");
var https = require("https");
// Simplified pricing lookup (in production, pull from AWS Pricing API)
var PRICING = {
"aws_instance": {
"t3.micro": 0.0104,
"t3.small": 0.0208,
"t3.medium": 0.0416,
"t3.large": 0.0832,
"t3.xlarge": 0.1664,
"m5.large": 0.096,
"m5.xlarge": 0.192,
"r5.large": 0.126,
"r5.xlarge": 0.252
},
"aws_db_instance": {
"db.t3.micro": 0.017,
"db.t3.small": 0.034,
"db.t3.medium": 0.068,
"db.r5.large": 0.25,
"db.r5.xlarge": 0.50
},
"aws_s3_bucket": {
"storage_per_gb": 0.023,
"requests_per_1000": 0.005
},
"aws_ebs_volume": {
"gp3_per_gb": 0.08,
"gp2_per_gb": 0.10,
"io1_per_gb": 0.125
}
};
var HOURS_PER_MONTH = 730;
function loadPlan(planPath) {
var raw = fs.readFileSync(planPath, "utf-8");
return JSON.parse(raw);
}
function estimateResource(resource) {
var type = resource.type;
var values = resource.change.after || {};
var estimate = { resource: type, name: resource.name, monthlyCost: 0, details: [] };
if (type === "aws_instance") {
var instanceType = values.instance_type || "t3.micro";
var hourlyRate = PRICING["aws_instance"][instanceType] || 0;
var monthlyCost = hourlyRate * HOURS_PER_MONTH;
estimate.monthlyCost = monthlyCost;
estimate.details.push(instanceType + " @ $" + hourlyRate + "/hr = $" + monthlyCost.toFixed(2) + "/mo");
}
if (type === "aws_db_instance") {
var dbClass = values.instance_class || "db.t3.micro";
var hourlyRate = PRICING["aws_db_instance"][dbClass] || 0;
var storageCost = (values.allocated_storage || 20) * PRICING["aws_ebs_volume"]["gp2_per_gb"];
var computeCost = hourlyRate * HOURS_PER_MONTH;
estimate.monthlyCost = computeCost + storageCost;
estimate.details.push(dbClass + " compute: $" + computeCost.toFixed(2) + "/mo");
estimate.details.push("Storage " + (values.allocated_storage || 20) + "GB: $" + storageCost.toFixed(2) + "/mo");
}
if (type === "aws_ebs_volume") {
var volType = values.type || "gp3";
var sizeGb = values.size || 20;
var rateKey = volType + "_per_gb";
var rate = PRICING["aws_ebs_volume"][rateKey] || 0.08;
estimate.monthlyCost = sizeGb * rate;
estimate.details.push(volType + " " + sizeGb + "GB @ $" + rate + "/GB = $" + estimate.monthlyCost.toFixed(2) + "/mo");
}
return estimate;
}
function estimatePlan(planPath) {
var plan = loadPlan(planPath);
var changes = plan.resource_changes || [];
var estimates = [];
var totalMonthly = 0;
changes.forEach(function(resource) {
var actions = resource.change.actions || [];
if (actions.indexOf("create") === -1 && actions.indexOf("update") === -1) {
return;
}
var estimate = estimateResource(resource);
if (estimate.monthlyCost > 0) {
estimates.push(estimate);
totalMonthly += estimate.monthlyCost;
}
});
return {
resources: estimates,
totalMonthlyCost: totalMonthly,
totalAnnualCost: totalMonthly * 12
};
}
function formatReport(result) {
var lines = [];
lines.push("=== Infrastructure Cost Estimate ===\n");
result.resources.forEach(function(r) {
lines.push(r.resource + "." + r.name + ": $" + r.monthlyCost.toFixed(2) + "/month");
r.details.forEach(function(d) {
lines.push(" - " + d);
});
lines.push("");
});
lines.push("-----------------------------------");
lines.push("Total Monthly: $" + result.totalMonthlyCost.toFixed(2));
lines.push("Total Annual: $" + result.totalAnnualCost.toFixed(2));
lines.push("-----------------------------------");
return lines.join("\n");
}
function checkBudget(result, monthlyBudget) {
if (result.totalMonthlyCost > monthlyBudget) {
console.error("BUDGET EXCEEDED: $" + result.totalMonthlyCost.toFixed(2) + " > $" + monthlyBudget.toFixed(2));
process.exit(1);
}
console.log("Budget OK: $" + result.totalMonthlyCost.toFixed(2) + " within $" + monthlyBudget.toFixed(2) + " limit");
}
// Main execution
var planPath = process.argv[2] || "plan.json";
var budget = parseFloat(process.argv[3]) || 5000;
var result = estimatePlan(planPath);
console.log(formatReport(result));
checkBudget(result, budget);
Run it:
node cost-estimator.js plan.json 2000
This outputs a human-readable report and exits non-zero if the monthly estimate exceeds the budget threshold. You can plug this directly into any CI pipeline.
Extending the Estimator for PR Comments
Add a function that posts the estimate as a GitHub PR comment:
var https = require("https");
function postGitHubComment(owner, repo, prNumber, body, token) {
var data = JSON.stringify({ body: body });
var options = {
hostname: "api.github.com",
path: "/repos/" + owner + "/" + repo + "/issues/" + prNumber + "/comments",
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"User-Agent": "cost-estimator",
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data)
}
};
var req = https.request(options, function(res) {
var responseBody = "";
res.on("data", function(chunk) { responseBody += chunk; });
res.on("end", function() {
if (res.statusCode === 201) {
console.log("Cost comment posted to PR #" + prNumber);
} else {
console.error("Failed to post comment: " + res.statusCode);
console.error(responseBody);
}
});
});
req.on("error", function(err) {
console.error("Request failed: " + err.message);
});
req.write(data);
req.end();
}
// Format as Markdown table for PR comment
function formatMarkdownReport(result) {
var lines = [];
lines.push("## Infrastructure Cost Estimate\n");
lines.push("| Resource | Name | Monthly Cost |");
lines.push("|----------|------|-------------|");
result.resources.forEach(function(r) {
lines.push("| " + r.resource + " | " + r.name + " | $" + r.monthlyCost.toFixed(2) + " |");
});
lines.push("");
lines.push("**Total Monthly: $" + result.totalMonthlyCost.toFixed(2) + "**");
lines.push("**Total Annual: $" + result.totalAnnualCost.toFixed(2) + "**");
return lines.join("\n");
}
Tagging Strategies for Cost Attribution
Cost estimation is only useful if you can attribute costs to the right teams and projects. Enforce tagging at the Terraform level:
# modules/required-tags/main.tf
variable "required_tags" {
type = map(string)
validation {
condition = contains(keys(var.required_tags), "team")
error_message = "The 'team' tag is required."
}
validation {
condition = contains(keys(var.required_tags), "environment")
error_message = "The 'environment' tag is required."
}
validation {
condition = contains(keys(var.required_tags), "cost-center")
error_message = "The 'cost-center' tag is required."
}
}
# Apply to all resources via default_tags
provider "aws" {
default_tags {
tags = {
team = var.required_tags["team"]
environment = var.required_tags["environment"]
cost-center = var.required_tags["cost-center"]
managed-by = "terraform"
}
}
}
Combine this with a CI check that rejects any Terraform plan creating untagged resources:
var fs = require("fs");
function checkTags(planPath, requiredTags) {
var plan = JSON.parse(fs.readFileSync(planPath, "utf-8"));
var violations = [];
plan.resource_changes.forEach(function(resource) {
var actions = resource.change.actions;
if (actions.indexOf("create") === -1) return;
var afterTags = (resource.change.after || {}).tags || {};
var afterDefaultTags = (resource.change.after || {}).tags_all || {};
var allTags = Object.assign({}, afterDefaultTags, afterTags);
requiredTags.forEach(function(tag) {
if (!allTags[tag]) {
violations.push(resource.address + " missing tag: " + tag);
}
});
});
if (violations.length > 0) {
console.error("Tag violations found:");
violations.forEach(function(v) { console.error(" - " + v); });
process.exit(1);
}
console.log("All resources properly tagged.");
}
checkTags(process.argv[2] || "plan.json", ["team", "environment", "cost-center"]);
Budget Guardrails in Pipelines
Hard budget limits prevent runaway costs. Here is a complete guardrail implementation:
# .github/workflows/cost-guardrail.yml
name: Cost Guardrail
on:
pull_request:
paths:
- 'terraform/**'
env:
MONTHLY_BUDGET_LIMIT: 5000
COST_INCREASE_THRESHOLD: 500
jobs:
cost-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate cost estimate
run: |
infracost breakdown --path=terraform \
--format=json \
--out-file=/tmp/cost.json
- name: Check budget
run: |
TOTAL=$(cat /tmp/cost.json | jq -r '.totalMonthlyCost // "0"')
echo "Estimated monthly cost: $$TOTAL"
if [ $(echo "$TOTAL > $MONTHLY_BUDGET_LIMIT" | bc) -eq 1 ]; then
echo "::error::Monthly cost $$TOTAL exceeds budget limit of $$MONTHLY_BUDGET_LIMIT"
exit 1
fi
- name: Check cost increase
if: github.event_name == 'pull_request'
run: |
git checkout ${{ github.event.pull_request.base.ref }} -- terraform/
infracost breakdown --path=terraform --format=json --out-file=/tmp/base-cost.json
git checkout ${{ github.sha }} -- terraform/
BASE=$(cat /tmp/base-cost.json | jq -r '.totalMonthlyCost // "0"')
NEW=$(cat /tmp/cost.json | jq -r '.totalMonthlyCost // "0"')
DIFF=$(echo "$NEW - $BASE" | bc)
echo "Cost change: $$DIFF/month"
if [ $(echo "$DIFF > $COST_INCREASE_THRESHOLD" | bc) -eq 1 ]; then
echo "::error::Cost increase $$DIFF exceeds threshold of $$COST_INCREASE_THRESHOLD"
exit 1
fi
Cost Optimization Recommendations
Beyond estimation, automate cost optimization suggestions. Build a rule engine that flags common waste:
var fs = require("fs");
var RULES = [
{
name: "oversized-dev-instances",
check: function(resource, env) {
if (resource.type !== "aws_instance") return null;
var instanceType = (resource.change.after || {}).instance_type || "";
var family = instanceType.split(".")[0];
var size = instanceType.split(".")[1];
var bigSizes = ["xlarge", "2xlarge", "4xlarge", "8xlarge"];
if (env !== "production" && bigSizes.indexOf(size) !== -1) {
return "Non-production instance " + resource.address + " uses " + instanceType + ". Consider downsizing to " + family + ".large";
}
return null;
}
},
{
name: "no-gp3-volumes",
check: function(resource) {
if (resource.type !== "aws_ebs_volume") return null;
var volType = (resource.change.after || {}).type;
if (volType === "gp2") {
return resource.address + " uses gp2. Migrate to gp3 for 20% cost savings with better performance.";
}
return null;
}
},
{
name: "single-az-rds",
check: function(resource, env) {
if (resource.type !== "aws_db_instance") return null;
var multiAz = (resource.change.after || {}).multi_az;
if (env !== "production" && multiAz === true) {
return resource.address + " has Multi-AZ enabled in non-production. Disable for 50% cost reduction.";
}
return null;
}
},
{
name: "missing-lifecycle-policy",
check: function(resource) {
if (resource.type !== "aws_s3_bucket") return null;
// Flag for manual review - lifecycle policies save significant storage costs
return resource.address + " — verify lifecycle policy is configured for object expiration.";
}
}
];
function analyzeOptimizations(planPath, environment) {
var plan = JSON.parse(fs.readFileSync(planPath, "utf-8"));
var recommendations = [];
(plan.resource_changes || []).forEach(function(resource) {
RULES.forEach(function(rule) {
var result = rule.check(resource, environment);
if (result) {
recommendations.push({ rule: rule.name, message: result });
}
});
});
return recommendations;
}
var recs = analyzeOptimizations(process.argv[2] || "plan.json", process.argv[3] || "staging");
if (recs.length > 0) {
console.log("=== Cost Optimization Recommendations ===\n");
recs.forEach(function(r) {
console.log("[" + r.rule + "] " + r.message);
});
} else {
console.log("No cost optimization recommendations.");
}
Complete Working Example
Here is a complete CI/CD pipeline that estimates infrastructure costs on every PR, comments the diff, and blocks merges that exceed budget thresholds. This ties together everything covered above.
Project Structure
my-infra/
├── .github/
│ └── workflows/
│ └── infracost.yml
├── terraform/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── scripts/
│ ├── cost-estimator.js
│ ├── tag-checker.js
│ └── cost-optimizer.js
├── policies/
│ └── cost-policy.rego
└── infracost.yml
Infracost Configuration
# infracost.yml
version: 0.1
projects:
- path: terraform
name: my-infra-prod
terraform_var_files:
- terraform.tfvars
terraform_vars:
environment: production
The Complete Pipeline
# .github/workflows/infracost.yml
name: Infrastructure Cost Analysis
on:
pull_request:
paths:
- 'terraform/**'
- 'policies/**'
permissions:
contents: read
pull-requests: write
env:
MONTHLY_BUDGET: 10000
MAX_COST_INCREASE: 1000
jobs:
cost-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Setup Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
path: base
- name: Generate base cost estimate
run: |
infracost breakdown \
--path=base/terraform \
--format=json \
--out-file=/tmp/infracost-base.json
- name: Checkout PR branch again
uses: actions/checkout@v4
- name: Generate PR cost diff
run: |
infracost diff \
--path=terraform \
--compare-to=/tmp/infracost-base.json \
--format=json \
--out-file=/tmp/infracost-diff.json
- name: Run custom tag checker
run: |
cd terraform && terraform init -backend=false
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > /tmp/plan.json
node scripts/tag-checker.js /tmp/plan.json
- name: Run cost optimizer
run: |
node scripts/cost-optimizer.js /tmp/plan.json ${{ vars.ENVIRONMENT || 'staging' }}
- name: Check budget guardrails
run: |
TOTAL=$(jq -r '.totalMonthlyCost // "0"' /tmp/infracost-diff.json)
DIFF=$(jq -r '.diffTotalMonthlyCost // "0"' /tmp/infracost-diff.json)
echo "Total monthly cost: $$TOTAL"
echo "Cost difference: $$DIFF"
OVER_BUDGET=$(echo "$TOTAL > $MONTHLY_BUDGET" | bc -l)
OVER_THRESHOLD=$(echo "$DIFF > $MAX_COST_INCREASE" | bc -l)
if [ "$OVER_BUDGET" -eq 1 ]; then
echo "::error::Total cost $$TOTAL exceeds monthly budget of $$MONTHLY_BUDGET"
echo "BUDGET_STATUS=FAILED" >> $GITHUB_ENV
elif [ "$OVER_THRESHOLD" -eq 1 ]; then
echo "::warning::Cost increase $$DIFF exceeds review threshold of $$MAX_COST_INCREASE"
echo "BUDGET_STATUS=WARNING" >> $GITHUB_ENV
else
echo "BUDGET_STATUS=OK" >> $GITHUB_ENV
fi
- name: Run policy checks
run: |
infracost breakdown --path=terraform --format=json | \
infracost policy check --policy-path=policies/cost-policy.rego || true
- name: Post PR comment
run: |
infracost comment github \
--path=/tmp/infracost-diff.json \
--repo=$GITHUB_REPOSITORY \
--github-token=${{ secrets.GITHUB_TOKEN }} \
--pull-request=${{ github.event.pull_request.number }} \
--behavior=update
- name: Fail if over budget
if: env.BUDGET_STATUS == 'FAILED'
run: |
echo "Pipeline blocked: infrastructure cost exceeds budget limit."
exit 1
This pipeline does the following on every pull request:
- Generates a baseline cost estimate from the target branch
- Generates a cost diff showing what the PR changes
- Checks that all new resources have required tags
- Runs cost optimization recommendations
- Validates against budget guardrails
- Runs OPA policy checks
- Posts a formatted cost comment on the PR
- Blocks the merge if the total cost exceeds the monthly budget
Common Issues and Troubleshooting
Infracost Shows $0 for All Resources
This usually means Infracost cannot find a matching pricing entry. Common causes: the region in your Terraform config does not match a valid AWS region string, or you are using a resource type that Infracost does not yet support. Run infracost breakdown --path=. --log-level=debug to see which resources are being skipped and why.
Terraform Plan Fails in CI Without Backend Access
Cost estimation does not require a real backend. Use terraform init -backend=false to skip backend initialization, then generate a plan with -target flags if needed. Alternatively, use infracost breakdown --path=. which runs its own internal HCL parser and does not require a full terraform init.
Cost Estimates Differ Between Local and CI
This happens when Terraform variable values differ between environments. Infracost uses the same variable resolution as Terraform, so ensure your CI environment has the same .tfvars files and environment variables as your local setup. Use --terraform-var-file to explicitly specify which variable files to load.
PR Comments Not Appearing
Verify that the GitHub token has pull-requests: write permission. For organization repositories, the token may need additional scopes. Also check that the --pull-request number is correct — in GitHub Actions, use ${{ github.event.pull_request.number }}, not ${{ github.event.number }}.
OPA Policy Checks Failing Unexpectedly
The JSON structure Infracost produces can change between versions. Pin your Infracost version in CI and test policies against the actual JSON output. Use infracost breakdown --format=json | jq . to inspect the structure your policy receives.
Cost Estimates for Usage-Based Resources Are Inaccurate
Resources like Lambda functions, API Gateway, and S3 request costs depend on actual usage, which cannot be predicted from Terraform alone. Use an infracost-usage.yml file to provide expected usage estimates:
# infracost-usage.yml
version: 0.1
resource_usage:
aws_lambda_function.api:
monthly_requests: 1000000
request_duration_ms: 250
aws_s3_bucket.data:
standard:
storage_gb: 500
monthly_tier_1_requests: 100000
Best Practices
Run cost estimation on every PR, not just periodically. Batch reviews miss the connection between a specific change and its cost impact. Per-PR analysis creates accountability.
Set two thresholds: a warning and a hard limit. Warnings notify reviewers that a change is expensive. Hard limits block merges outright. A typical setup is a warning at $200/month increase and a hard block at $1,000/month.
Version your cost policies alongside infrastructure code. Store OPA policies and Infracost configuration in the same repository as Terraform. This ensures cost rules evolve with the infrastructure.
Tag every resource from day one. Retroactively adding tags is painful and error-prone. Enforce tags in CI with a tag checker that rejects untagged resources before they are created.
Use usage files for serverless and consumption-based resources. Static analysis cannot estimate Lambda invocation costs. Maintain
infracost-usage.ymlwith realistic usage projections and update them monthly based on actual billing data.Separate cost estimation from cost enforcement. Estimation comments on PRs are informational. Budget guardrails are enforcement. Keep them in separate pipeline steps so a comment failure does not block deployment and a guardrail failure does not suppress the cost report.
Review cost estimates during quarterly planning. Aggregate Infracost data across all PRs merged in a quarter to understand infrastructure cost trends. This feeds into capacity planning and budget forecasting.
Treat cost estimation failures as pipeline failures. If Infracost cannot generate an estimate (missing pricing data, parse errors), fail the pipeline. Silent cost estimation failures are worse than no estimation at all because they create a false sense of coverage.
References
- Infracost Documentation — Official docs for CLI setup, CI/CD integration, and configuration
- Infracost GitHub Actions — Pre-built GitHub Actions for cost estimation workflows
- Open Policy Agent — Policy engine used for custom cost rules
- AWS Pricing API — Programmatic access to AWS service pricing
- FinOps Foundation — Industry framework for cloud financial management
- Terraform Plan JSON Format — Reference for parsing Terraform plan output
- Infracost Usage File Spec — How to estimate costs for usage-based resources