DevOpsOctober 10, 2026 9 min read

Infrastructure cost in the pull request: estimating a Terraform plan before the merge

The cost of an infrastructure change usually shows up on the bill a month later. Here is how AAA Sentinel comments on every pull request with how much a Terraform plan raises or lowers monthly spend, what leaves your machine, how to set it up in GitHub Actions and what it cannot price yet.

This article is also available in:esfrpt

One line in a .tf file changes m5.large to m5.xlarge. The review focuses on whether the code is correct, nobody asks what it costs and the change is merged. A month later, the EC2 bill has gone up and someone has to reconstruct what happened. In this article we show how to put the price of each change in the pull request itself with AAA Sentinel, while changing your mind is still cheap.

The problem: cost arrives a month late

In most teams, infrastructure cost is looked at once it is already deployed. That has three consequences:

  • The person deciding the change does not see its price: the developer picks an instance size without knowing what it costs per month.
  • The person who sees the price cannot decide: finance finds the increase on the bill, when the resource is already in production and someone has to be convinced to change it.
  • Fixing it late costs more: a size changes in seconds in a pull request, and in production it needs a maintenance window.

This is what is known as shift-left FinOps: bringing cost information to the moment the technical decision is made, just as was done earlier with testing and security.

What you gain

With cost in the pull request:

  • Every infrastructure change reaches review with its estimated monthly delta.
  • The cost conversation happens between the person writing the change and the person reviewing it, not a month later between finance and engineering.
  • You also see what could not be priced and why, instead of a figure that looks complete when it is not.
  • You do not add a point of failure to your deployment: if the estimate fails, the pipeline carries on.

How AAA Sentinel solves it

The piece is a CLI published on npm, @aaa-sentinel/iac-cost. It reads the output of terraform show -json, reduces it on your machine to the bare minimum, asks the AAA Sentinel API for the estimate and writes to standard output a Markdown comment ready to post on the pull request. It comes with examples for GitHub Actions and Bitbucket Pipelines that post the comment and update it on every push.

Plans: you need Pro or Business and an OAuth2 application with the iac:estimate permission. Pro allows two OAuth2 applications and Business, ten.

What leaves your machine (and what does not)

The output of terraform show -json carries CIDR ranges, account identifiers and, often, secrets in clear text. That is why the plan does not leave your machine. Before anything is sent, the CLI reduces it locally to a whitelist:

  • The resource type and the action: create, update, delete or replace.
  • How many instances the block produces: count and for_each are folded into a quantity.
  • The handful of attributes that move the price: the instance size and, when the resource carries it, the region.

Not even resource addresses, like aws_instance.web[0], are sent: in a public repository, the comment would be a map of your infrastructure. The projection is never written to the CI log either. And the iac:estimate permission grants nothing else: it cannot read your accounts, findings or reports, so it is safe to store in a CI that third parties can trigger.

Worked example: ACME's pull request

ACME, a fictional company, runs three application servers on AWS, in us-east-1, at size m5.large. A pull request moves them to m5.xlarge and, along the way, adds a staging database and a security group rule. The provider "aws" block sets the region as a constant value. With approximate EC2 on-demand Linux list prices:

ACME · size change in the pull request

m5.large (before)
about 0.096 USD/hour × 730 h = 70.08 USD/month per instance
m5.xlarge (after)
about 0.192 USD/hour × 730 h = 140.16 USD/month per instance
Delta per instance
+70.08 USD/month
Delta for the 3 instances
+210.24 USD/month
aws_db_instance (new)
not priced: RDS has no catalog yet
aws_security_group_rule (new)
no cost by design

Step 1: create the OAuth2 application

The CLI authenticates with OAuth2 Client Credentials. An owner or admin of the organization creates the application under Developer → "OAuth2 applications":

  • In "My applications", click "New application".
  • In "New OAuth2 application", fill in "Application name" (for example, ci-iac-cost).
  • In "Permissions (scopes)", inside the "Read" group, tick only iac:estimate, "Estimate the cost of a Terraform plan".
  • Click "Create application" and copy the client_id and the client_secret.

The client_secret is shown only once. Store it straight away as an encrypted CI secret: on GitHub, as IAC_COST_CLIENT_ID and IAC_COST_CLIENT_SECRET in the repository secrets.

Step 2: try it locally

Before touching the pipeline, it is worth trying it on your machine with Node.js 20 or later:

Terminalbash
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json

export IAC_COST_CLIENT_ID=<client_id>
export IAC_COST_CLIENT_SECRET=<client_secret>
npx @aaa-sentinel/iac-cost plan.json

CLI environment variables

IAC_COST_CLIENT_ID
Required · client_id of the OAuth2 application
IAC_COST_CLIENT_SECRET
Required · client_secret, stored as an encrypted secret
IAC_COST_BASE_URL
Optional · defaults to https://api.aaa-sentinel.com; must be https
  • The comment goes to standard output and warnings to standard error. If there is nothing to say, standard output is empty.
  • It also accepts the plan on standard input, piped into npx @aaa-sentinel/iac-cost.

Step 3: comment on every pull request with GitHub Actions

This is the workflow from the package README. It posts one comment per pull request and updates it in place on later pushes, instead of stacking a new one each time. It uses the token the run already injects: no personal access token, no app installation.

.github/workflows/infrastructure-cost.ymlyaml
name: Infrastructure cost
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  cost:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: terraform init
      - run: terraform plan -out=plan.tfplan
      - run: terraform show -json plan.tfplan > plan.json

      - name: Estimate cost
        env:
          IAC_COST_CLIENT_ID: ${{ secrets.IAC_COST_CLIENT_ID }}
          IAC_COST_CLIENT_SECRET: ${{ secrets.IAC_COST_CLIENT_SECRET }}
        run: npx --yes @aaa-sentinel/iac-cost plan.json > comment.md

      - name: Comment on the pull request
        continue-on-error: true
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.number }}
        run: |
          [ -s comment.md ] || exit 0
          id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
                 --jq '.[] | select((.body // "") | contains("<!-- aaa-sentinel:iac-cost -->")) | .id' \
               | head -n1)
          if [ -n "$id" ]; then
            gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md
          else
            gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md
          fi
  • Add your cloud credential steps before terraform plan, as in any Terraform pipeline.
  • continue-on-error is not decoration: without it, a GitHub API hiccup while posting the comment would fail the job.
  • On a pull request from a fork, GitHub gives the run a read-only token and the comment step is skipped. That is a GitHub restriction that applies to every tool that comments on pull requests.

And if you use Bitbucket Pipelines

Bitbucket does not inject a token that can write to the repository. Create a Repository Access Token with the pullrequest:write scope and store it as a secured repository variable named BB_TOKEN. The set +e and the final exit 0 play the same role as continue-on-error on GitHub.

bitbucket-pipelines.ymlyaml
pipelines:
  pull-requests:
    '**':
      - step:
          name: Infrastructure cost
          image: hashicorp/terraform:latest
          script:
            - apk add --no-cache nodejs npm curl jq
            - terraform init
            - terraform plan -out=plan.tfplan
            - terraform show -json plan.tfplan > plan.json
            - npx --yes @aaa-sentinel/iac-cost plan.json > comment.md
            - |
              set +e
              [ -s comment.md ] || exit 0
              API="https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pullrequests/$BITBUCKET_PR_ID/comments"
              BODY=$(jq -Rs . < comment.md)
              ID=$(curl -sf -H "Authorization: Bearer $BB_TOKEN" "$API?pagelen=100" \
                    | jq -r '[.values[] | select((.content.raw // "") | contains("<!-- aaa-sentinel:iac-cost -->"))][0].id // empty')
              if [ -n "$ID" ]; then
                curl -sf -X PUT -H "Authorization: Bearer $BB_TOKEN" -H 'Content-Type: application/json' \
                  -d "{\"content\":{\"raw\":$BODY}}" "$API/$ID"
              else
                curl -sf -X POST -H "Authorization: Bearer $BB_TOKEN" -H 'Content-Type: application/json' \
                  -d "{\"content\":{\"raw\":$BODY}}" "$API"
              fi
              exit 0

Step 4: read the comment

For ACME's pull request, the comment looks like this (excerpt; the comment is always written in English):

Pull request comment (excerpt)markdown
**Coverage: 3 of 4 billable resources priced** (1 more have no cost by design).

**Estimated change: +$210.24/mo** (list price, USD)

| Resource | Change | Qty | Delta |
|---|---|---|---|
| `aws_instance` | update | 3 | +$210.24/mo |

<details><summary>Not priced (1 resources)</summary>

| Resource | Change | Qty | Why |
|---|---|---|---|
| `aws_db_instance` | new | 1 | no matching price found in the catalog |

</details>
  • Coverage comes first, not the total: 3 of 4 billable resources have a price. The security group rule does not count, because it generates no bill.
  • The size change is priced on both sides: the delta is what the after costs minus what the before cost, multiplied by the 3 instances. A replacement is treated the same way, as a delete plus a create.
  • The database shows up as not priced, with its reason. It is never counted as free.

Why it never blocks the merge

The CLI exit code is always 0. A missing credential, a network error, an unreadable plan or an AAA Sentinel outage prints a one-line warning and the pipeline carries on. It is a deliberate decision: a cost comment is not a quality gate, and a third party's availability must not become a dependency of your deployments. The CLI does not offer a mode that fails the build when cost exceeds a threshold; the decision stays with the human review of the pull request.

Reading the number

The figure in the comment has a precise meaning:

  • It is list price. Neither AWS nor Azure returns what you pay: contract discounts and reservations or Savings Plans are invisible at pull request time. If a reservation already covers the new resource, the real marginal cost is zero.
  • It is in USD, the currency the providers publish in. Converting it would stack an estimate on an estimate, and the same pull request would show a different figure every morning.
  • If fewer than half of the billable resources have a price, the breakdown is published but the total is withheld: a total built from 2 resources out of 30 is not a partial estimate, it is noise.
  • An unknown region is treated as an unsupported one. In AWS the region lives in the provider block; if it comes from a variable or an aliased provider, the plan does not resolve it and the comment says so instead of using another region's price.

What it does not do yet

The limits matter, because a comment that looks complete when it is not is worse than none:

  • Today it prices only two things: on-demand EC2 instances on AWS (aws_instance) and Azure virtual machines (azurerm_linux_virtual_machine, azurerm_windows_virtual_machine and their scale sets).
  • Everything else is reported as not priced, with its reason: RDS, EBS volumes, load balancers, NAT, storage and any other resource type.
  • Google Cloud is recognized, but its price catalog is not connected yet. Other providers, such as Kubernetes, Datadog or Cloudflare, are not covered.
  • aws_instance is priced as Linux: the plan does not say which operating system the AMI runs.
  • The EC2 price is the instance only, without the root volume or data transfer.
  • A single plan supports at most 5 priced regions, counting both clouds. Above that, the estimate fails and the pipeline carries on without a comment.

Checklist to get started

With this in place, every infrastructure change reaches review with its price:

  • Pro or Business plan.
  • An OAuth2 application with only the iac:estimate permission, created under Developer → "OAuth2 applications".
  • The client_id and client_secret stored as encrypted CI secrets.
  • A local test with terraform show -json and npx @aaa-sentinel/iac-cost.
  • The GitHub Actions workflow, or the Bitbucket pipeline, in the Terraform repository.
  • The AWS region set as a constant in the provider block, so the plan resolves it.
  • A team that knows how to read the comment: coverage first, list price, in USD.