Build a S3 Cost Budget Gate Using Infracost Diff and GitHub Actions
Written by
Atlas Node
The problem I kept running into
I was helping a team keep a strict monthly cloud budget while they shipped changes every day. The usual setup was “monitor spend with dashboards.” That’s useful, but it doesn’t stop a cost regression from landing in main.
What I wanted was something much more specific and deterministic:
- Measure how much an individual PR might increase AWS costs.
- Fail the PR when the estimated delta exceeds a small “cost budget gate.”
- Keep the logic simple enough that platform engineers could maintain it without being cloud-cost gurus.
The niche piece that finally worked for me was combining:
- Infracost (a tool that estimates cloud costs from Terraform plans),
- a PR comment that shows the cost delta, and
- a hard budget gate that blocks merges if the delta crosses a threshold.
Below is the setup I built and used.
What’s in the pipeline (in plain terms)
- Terraform plan: an output that says what changes Terraform intends to make.
- Infracost: reads Terraform plans and estimates the monthly cost difference caused by those changes.
- Cost budget gate: a rule like “if estimated monthly increase is > $5, block the PR.”
I used AWS + Terraform plans, but the pattern works wherever Infracost can read plans.
Assumptions I used
- You already generate Terraform plans in CI (or can).
- Your infrastructure is described with Terraform.
- You can run Infracost in GitHub Actions.
This example gates monthly AWS cost increase for changes in Terraform.
Step 1: Generate a Terraform plan artifact
I store the plan as a file so Infracost can read it later in the same job.
Example GitHub Actions workflow
# .github/workflows/finops-cost-gate.yml name: FinOps Cost Budget Gate on: pull_request: types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write jobs: cost-gate: runs-on: ubuntu-latest env: # Budget gate in USD per month for the PR. COST_BUDGET_GATE_USD: "5" # Optional: where your Terraform lives. TF_WORKDIR: "." steps: - name: Checkout uses: actions/checkout@v4 # Terraform auth is environment-specific. This is a placeholder. # The plan step needs credentials to read modules/providers and data sources. - name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.7.5 - name: Terraform Init working-directory: ${{ env.TF_WORKDIR }} run: terraform init -input=false - name: Terraform Plan working-directory: ${{ env.TF_WORKDIR }} run: terraform plan -input=false -no-color -out=tfplan.binary - name: Upload Terraform plan artifact uses: actions/upload-artifact@v4 with: name: tfplan path: ${{ env.TF_WORKDIR }}/tfplan.binary
Why the artifact matters: Infracost needs a stable plan input. If you try to run Infracost on-the-fly with no persisted plan, you can end up with inconsistencies between steps.
Step 2: Run Infracost and produce a cost delta
To compute a delta, Infracost compares “baseline” and “updated.” In practice, the most reliable baseline is the target branch (usually main) plan.
So the workflow needs two plans:
- plan for the PR head
- plan for the base branch (
github.base_ref)
Working workflow with baseline + PR plans + diff
Here’s the full job I used. It generates both plans, runs Infracost on them, and parses the JSON so it can enforce the budget gate.
# .github/workflows/finops-cost-gate.yml name: FinOps Cost Budget Gate on: pull_request: types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write jobs: cost-gate: runs-on: ubuntu-latest env: COST_BUDGET_GATE_USD: "5" # block if estimated monthly increase > this TF_WORKDIR: "." steps: - name: Checkout PR head uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} - name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.7.5 # --- Plan for PR head --- - name: Terraform Init (PR) working-directory: ${{ env.TF_WORKDIR }} run: terraform init -input=false - name: Terraform Plan (PR) working-directory: ${{ env.TF_WORKDIR }} run: terraform plan -input=false -no-color -out=pr.tfplan.binary - name: Upload PR plan artifact uses: actions/upload-artifact@v4 with: name: pr-plan path: ${{ env.TF_WORKDIR }}/pr.tfplan.binary # --- Plan for base branch --- - name: Checkout base branch uses: actions/checkout@v4 with: ref: ${{ github.base_ref }} - name: Terraform Init (Base) working-directory: ${{ env.TF_WORKDIR }} run: terraform init -input=false - name: Terraform Plan (Base) working-directory: ${{ env.TF_WORKDIR }} run: terraform plan -input=false -no-color -out=base.tfplan.binary - name: Upload base plan artifact uses: actions/upload-artifact@v4 with: name: base-plan path: ${{ env.TF_WORKDIR }}/base.tfplan.binary infracost-diff-and-gate: runs-on: ubuntu-latest needs: cost-gate env: COST_BUDGET_GATE_USD: "5" TF_WORKDIR: "." steps: - name: Download PR plan artifact uses: actions/download-artifact@v4 with: name: pr-plan path: plans - name: Download base plan artifact uses: actions/download-artifact@v4 with: name: base-plan path: plans - name: Install Infracost run: | curl -fsSL https://www.infracost.io/install.sh | sh infracost --version - name: Create Infracost diff JSON env: # Infracost needs an API key for some features/pricing sources. # If you don't use the Infracost dashboard, you can still run with public pricing # depending on your configuration. This env var is optional. INFRACOST_API_KEY: ${{ secrets.INFRACOST_API_KEY }} run: | set -euo pipefail # Move plans to distinct filenames mv plans/pr.tfplan.binary plans/pr.plan mv plans/base.tfplan.binary plans/base.plan # Infracost diff: # - --format json writes machine-readable output # - --compare-to / --path arguments tell it how to diff infracost diff \ --format json \ --out-file=infracost-diff.json \ --path plans/pr.plan \ --compare-to plans/base.plan ls -lah infracost-diff.json - name: Parse monthly cost delta and enforce budget gate env: COST_BUDGET_GATE_USD: ${{ env.COST_BUDGET_GATE_USD }} run: | set -euo pipefail # The JSON contains cost breakdown fields. # The exact schema can vary by Infracost version, but "total" deltas # are typically present. This extraction is defensive. python3 - << 'PY' import json from decimal import Decimal gate = Decimal(str(float(__import__("os").environ["COST_BUDGET_GATE_USD"]))) with open("infracost-diff.json","r") as f: data = json.load(f) # Helper to find a numeric total delta. # Commonly we look for something like: # data["projects"][...]["totalMonthlyCostDelta"]["value"] total_delta = None projects = data.get("projects", []) for p in projects: # Try a few likely keys (defensive for version differences) for key_path in [ ("totalMonthlyCostDelta", "value"), ("totalMonthlyCostDelta", "currency"), ]: pass tmd = p.get("totalMonthlyCostDelta") or p.get("totalMonthlyCost") or {} if isinstance(tmd, dict): if "value" in tmd: total_delta = Decimal(str(tmd["value"])) break if total_delta is None: raise SystemExit("Could not find total monthly cost delta in infracost-diff.json") print(f"Estimated total monthly cost delta: ${total_delta}") # Gate means: block when PR increases cost beyond threshold. if total_delta > gate: raise SystemExit(f"Cost gate triggered: ${total_delta} > ${gate}") print("Cost gate passed.") PY
Why parsing JSON is worth it
A lot of people parse Infracost’s human-readable output. I did that first. It was brittle: formatting changes broke the workflow.
Parsing JSON made the gate stable across environments.
Step 3: (Optional but useful) Comment the delta on the PR
After the gate passes or fails, I like posting a summarized delta so developers can see why a PR was blocked.
This step uses Infracost’s JSON to produce a short message.
- name: Comment cost diff summary if: always() uses: actions/github-script@v7 with: script: | const fs = require('fs'); const raw = fs.readFileSync('infracost-diff.json', 'utf8'); const data = JSON.parse(raw); function findDelta() { const projects = data.projects || []; for (const p of projects) { const t = p.totalMonthlyCostDelta; if (t && typeof t.value === 'number') return t.value; } return null; } const delta = findDelta(); const msg = delta === null ? 'Infracost diff ran, but totalMonthlyCostDelta was not found.' : `Infracost estimated total monthly cost delta: $${delta}`; const { owner, repo } = context.repo; const prNumber = context.payload.pull_request.number; await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: msg });
Why I keep it minimal: commenting the whole breakdown tends to bury the signal in noise. The gate decision usually depends on a single number: the total estimated monthly delta.
A concrete “what happens when it runs” example
When a PR adds, say, one new RDS instance with small storage and modest compute:
- Terraform plan for PR head says an extra
aws_db_instancewill be created. - Terraform plan for base branch doesn’t include it.
- Infracost diff computes a monthly cost delta (for the new instance) and writes
infracost-diff.json. - The Python script reads
totalMonthlyCostDelta.value. - If delta is
$12/monthandCOST_BUDGET_GATE_USD=5, the step exits non-zero. - GitHub marks the PR workflow as failed—so the PR cannot merge (if branch protection requires checks).
That loop is exactly what transforms FinOps from “reporting” into “guardrails.”
Guardrails I learned to include
Pin your tooling versions
I pinned Terraform. I also kept Infracost consistent across runs. Cost estimation changes can cause noisy gates.
Handle “no delta found” explicitly
If the JSON extraction fails, it’s safer to fail the job than to assume $0 delta.
Use a small budget gate for early enforcement
I started with something like $5 to validate the pipeline, then tuned it based on real PRs.
Closing summary
I built a PR-level FinOps control that blocks merges when Infracost estimates a Terraform change will increase AWS monthly spend beyond a fixed budget gate. The key wins for me were generating stable baseline vs PR Terraform plans, using infracost diff to compute a monthly delta in JSON, and enforcing the decision by parsing that JSON in Python so the gate stays reliable over time.