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:
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.jsonCLI 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.
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.
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 0Step 4: read the comment
For ACME's pull request, the comment looks like this (excerpt; the comment is always written in English):
**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.