A reviewer approving a Terraform pull request sees instance_type = "r6i.8xlarge" and count = 6. Nobody in that review thinks "about 9,000 USD a month", because nobody has the price list in their head. A month later the number appears in a cost report, the change is in production, and undoing it is a project.

Cost estimates in pull requests put the number where the decision is made. We added them to our infrastructure repositories a year ago. This guide covers how it works, how we set it up, and where estimates mislead.

How estimation works

Tools like Infracost parse Terraform (the plan JSON or the HCL), match each resource to cloud pricing data, and compute a monthly cost for the old and new configuration. The difference is posted as a PR comment:

Project: prod-eu/data-platform

+ aws_instance.spark_worker[0..5]
  +$8,935 ($1,489 x 6)
    + Instance usage (Linux/UNIX, on-demand, r6i.8xlarge)   730 hours   $1,472
    + root_block_device: Storage (gp3)                      200 GB      $17

~ aws_db_instance.analytics
  +$1,210 ($1,640 -> $2,850)
    ~ Database instance (db.r6g.2xlarge -> db.r6g.4xlarge)

Monthly cost change for prod-eu/data-platform: +$10,145 (+38%)

Resources with fixed prices (instances, databases, load balancers, provisioned storage) are estimated well. Usage-based resources (data transfer, S3 requests, Lambda invocations, NAT gateway processing) have no cost without usage numbers.

Setup in CI

name: infracost
on: pull_request
jobs:
  cost:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v5
        with: {ref: "${{ github.base_ref }}"}
      - uses: infracost/actions/setup@v3
        with: {api-key: "${{ secrets.INFRACOST_API_KEY }}"}
      - run: infracost breakdown --config-file=infracost.yml --format=json --out-file=/tmp/base.json
      - uses: actions/checkout@v5
      - run: |
          infracost diff --config-file=infracost.yml --compare-to=/tmp/base.json \
            --format=json --out-file=/tmp/diff.json
          infracost comment github --path=/tmp/diff.json --repo=$GITHUB_REPOSITORY \
            --pull-request=${{ github.event.pull_request.number }} \
            --github-token=${{ github.token }} --behavior=update

infracost.yml lists the projects (root modules) in the repository, so monorepos get one section per stack. The comment is updated on each push instead of adding new ones.

The tool sends resource metadata to its pricing API, not secrets or state. We reviewed that before enabling it; self-hosted pricing APIs exist for organisations that require it.

Usage files for usage-based resources

For resources priced by usage, a usage file supplies estimates per resource:

version: 0.1
resource_usage:
  aws_nat_gateway.main:
    monthly_data_processed_gb: 1800
  aws_lambda_function.thumbnailer:
    monthly_requests: 1200000
    request_duration_ms: 400
  aws_s3_bucket.media:
    standard:
      storage_gb: 5200
      monthly_tier_1_requests: 900000

We generate the numbers once a month from actual usage in the Cost and Usage Report, so estimates for existing resources reflect reality. For new usage-based resources, authors fill in an estimate, which forces the conversation "how much traffic will this get?"

Thresholds and policies

Comments alone get ignored once people are used to them. We added two rules:

  • Any change above +1,000 USD per month, or +20 percent for a project, requests a review from the FinOps channel's rotation. Not a block; a second pair of eyes.
  • Policies for known expensive mistakes, written as policy-as-code checks on the plan: previous-generation instance types, gp2 volumes, provisioned IOPS without a comment explaining why, and NAT gateways in non-production accounts.
deny[msg] {
  r := input.resource_changes[_]
  r.type == "aws_ebs_volume"
  r.change.after.type == "gp2"
  msg := sprintf("%s uses gp2; use gp3 (cheaper, configurable performance)", [r.address])
}

What estimates do not tell you

  • Discounts. Estimates use list prices unless configured otherwise. Savings Plans and negotiated rates make actual cost lower; commitments you cannot use make it effectively higher.
  • Runtime behaviour. Autoscaling groups, Kubernetes node pools and serverless costs depend on load. The estimate shows the configured minimum or the usage file.
  • Things outside Terraform. Resources created by Kubernetes (load balancers, volumes), by applications, or by hand are invisible.
  • Second-order effects. Moving a service to another region adds data transfer that appears nowhere in the diff.

Treat the estimate as a prompt for a conversation, not as a forecast.

Results after a year

Out of about 2,100 infrastructure pull requests, 140 crossed the review threshold. Around a third of those changed before merge: smaller instance types after a capacity discussion, gp3 instead of io2 where latency requirements were softer than assumed, and two NAT gateways in development accounts removed in favour of VPC endpoints. Nobody can say exactly how much spend was avoided, but the conversations happened before deployment instead of after the invoice, which was the goal. Engineers also started adding cost notes to pull request descriptions on their own for large changes.

Keeping estimates honest

Once a month we compare the estimated monthly cost of each Terraform project with its actual effective cost from billing data. Large gaps point to missing usage files, resources created outside Terraform, or discounts the estimate ignores. Publishing that comparison next to the estimates keeps everyone calibrated about how much to trust them.

Common mistakes

Mistake Why it hurts Do this instead
No usage files Usage-based resources show 0 USD Generate usage from real data monthly
Blocking merges on cost Engineers route around it Request review above thresholds
Comment spam on every push Ignored Update one comment per PR
Trusting estimates as forecasts Surprises from discounts and load Compare with actuals monthly
Only new resources reviewed Expensive modifications missed Diff base vs head for all changes

Key takeaways

  • Put the price next to the change, in the pull request.
  • Use usage files based on actual consumption for usage-priced resources.
  • Ask for review above thresholds instead of blocking.
  • Encode known expensive mistakes as policy checks.
  • Remember what estimates cannot see.

References