This content originally appeared on HackerNoon and was authored by emmanuela Opurum
In Part 1, we built a pipeline that builds, tests, and deploys reliably. In Part 2, we added AI-powered security scanning that blocks bad code before it reaches staging. In this final part, we solve the last gap: cost visibility.
Right now, a developer can open a PR that changes an ECS task definition, a Terraform RDS instance type, or a CloudFront distribution config, and nobody knows the cost impact until the AWS invoice arrives three weeks later. We're going to fix that.
By the end of this article, your pipeline will estimate the cloud cost delta of every PR, post a clear cost report as a PR comment, and block deployment if the projected increase exceeds your team's budget threshold.
Why Cost Belongs in the Pipeline
This needs to be said plainly: cloud cost is an engineering problem, not a finance problem.
The teams that control their cloud spend are the ones where cost visibility is embedded in the developer workflow, not delegated to a FinOps function that publishes monthly reports. A monthly report tells you what happened. A pipeline cost gate changes what engineers decide to do in the first place.
The Flexera 2026 State of the Cloud Report found that organisations waste an average of 28% of their cloud spend. That's not a procurement failure. That's an architecture and tooling failure. Engineers make decisions every day that affect cloud cost, instance types, retention policies, replication factors, caching strategies, without any cost signal at the point of decision.
A CI/CD cost gate is that signal. Here's how to build one.
What We're Adding
PR opened with Terraform changes
↓
Infracost runs against the PR diff
↓
Cost delta calculated (current vs proposed)
↓
Report posted as PR comment (breakdown by resource)
↓
Budget gate: cost increase > threshold?
YES → Block merge + Slack alert to engineering lead
NO → Pipeline continues to security scan + deploy
The Stack
- Infracost — open source cloud cost estimation for Terraform
- GitHub Actions — runs the cost check on every PR
- Infracost Cloud (free tier) — stores historical cost data and trend analysis
- Slack — alerts when the budget gate triggers
Step 1: Add Terraform to the Project
If you're not already using Terraform, here's a minimal configuration for the ECS service we deployed in Part 1:
hcl
# infra/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket"
key = "cicd-demo/terraform.tfstate"
region = "eu-west-1"
}
}
provider "aws" {
region = var.aws_region
}
# ECS Cluster
resource "aws_ecs_cluster" "main" {
name = "${var.app_name}-${var.environment}"
setting {
name = "containerInsights"
value = "enabled"
}
}
# ECS Task Definition
resource "aws_ecs_task_definition" "app" {
family = "${var.app_name}-${var.environment}"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.task_cpu
memory = var.task_memory
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([{
name = var.app_name
image = "${var.ecr_repository_url}:latest"
portMappings = [{ containerPort = 3000, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/${var.app_name}-${var.environment}"
awslogs-region = var.aws_region
awslogs-stream-prefix = "ecs"
}
}
}])
}
# ECS Service
resource "aws_ecs_service" "app" {
name = "${var.app_name}-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = var.app_name
container_port = 3000
}
}
# RDS Instance (PostgreSQL)
resource "aws_db_instance" "main" {
identifier = "${var.app_name}-${var.environment}"
engine = "postgres"
engine_version = "16.1"
instance_class = var.db_instance_class
allocated_storage = var.db_storage_gb
storage_encrypted = true
deletion_protection = var.environment == "production" ? true : false
skip_final_snapshot = var.environment != "production"
db_name = var.app_name
username = var.db_username
password = var.db_password
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.main.name
backup_retention_period = var.environment == "production" ? 7 : 1
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
\
# infra/variables.tf
variable "app_name" { default = "cicd-demo" }
variable "environment" { default = "staging" }
variable "aws_region" { default = "eu-west-1" }
variable "task_cpu" { default = 256 }
variable "task_memory" { default = 512 }
variable "desired_count" { default = 1 }
variable "db_instance_class" { default = "db.t3.micro" }
variable "db_storage_gb" { default = 20 }
variable "ecr_repository_url" {}
variable "private_subnet_ids" { type = list(string) }
variable "db_username" {}
variable "db_password" { sensitive = true }
Step 2: Install Infracost and Get Your API Key
Sign up at infracost.io, the free tier is sufficient for this setup.
# Install locally for testing
brew install infracost # macOS
# or
curl -fsSL https://raw.githubusercontent.com/infracost/infracost/master/scripts/install.sh | sh
# Authenticate
infracost auth login
# Test locally against your Terraform directory
infracost breakdown --path infra/
You'll see output like this:
Project: cicd-demo
Name Monthly Cost
aws_ecs_service.app
└─ Per GB per hour (Linux/UNIX, Fargate) $9.55
aws_db_instance.main
└─ Database instance (db.t3.micro, 730 hrs) $15.33
└─ Storage (gp2, 20 GB) $2.30
OVERALL TOTAL $27.18
Copy your API key from the Infracost dashboard and add it to GitHub Secrets as INFRACOST_API_KEY.
Step 3: Add the Cost Gate Workflow
Create a new workflow specifically for cost checking:
# .github/workflows/cost-gate.yml
name: Cloud Cost Gate
on:
pull_request:
paths:
- 'infra/**'
- '*.tf'
- '**/*.tf'
jobs:
cost-check:
name: Cloud Cost Estimation
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
- name: Setup Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate Infracost diff
run: |
# Estimate cost of current main branch (baseline)
git fetch origin main
git checkout origin/main -- infra/
infracost breakdown \
--path infra/ \
--format json \
--out-file /tmp/infracost-base.json
# Restore PR branch infra
git checkout HEAD -- infra/
# Generate diff between base and PR
infracost diff \
--path infra/ \
--compare-to /tmp/infracost-base.json \
--format json \
--out-file /tmp/infracost-diff.json
- name: Parse cost delta and apply budget gate
id: cost-gate
env:
BUDGET_THRESHOLD: ${{ vars.MONTHLY_BUDGET_THRESHOLD || '50' }}
run: |
node - <<'EOF'
const fs = require('fs');
const diff = JSON.parse(fs.readFileSync('/tmp/infracost-diff.json', 'utf8'));
const totalDiff = parseFloat(diff.diffTotalMonthlyCost || 0);
const threshold = parseFloat(process.env.BUDGET_THRESHOLD);
console.log(`Cost delta: $${totalDiff.toFixed(2)}/month`);
console.log(`Budget threshold: $${threshold}/month`);
const blocked = totalDiff > threshold;
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`cost_delta=${totalDiff.toFixed(2)}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`blocked=${blocked}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`threshold=${threshold}\n`);
if (blocked) {
console.log(`GATE TRIGGERED: $${totalDiff.toFixed(2)} exceeds $${threshold} threshold`);
}
EOF
- name: Post cost report as PR comment
uses: infracost/actions/comment@v3
with:
path: /tmp/infracost-diff.json
behavior: update
tag: infracost-cost-gate
- name: Post budget gate status to PR
uses: actions/github-script@v7
with:
script: |
const delta = '${{ steps.cost-gate.outputs.cost_delta }}';
const blocked = '${{ steps.cost-gate.outputs.blocked }}' === 'true';
const threshold = '${{ steps.cost-gate.outputs.threshold }}';
const icon = blocked ? ' ' : '';
const status = blocked ? 'BUDGET GATE TRIGGERED' : 'Within budget threshold';
const colour = blocked ? '> ' : '> ';
const body = `## ${icon} Cloud Cost Gate: ${status}
${colour} **Monthly cost delta: $${delta}** (threshold: $${threshold}/month)
${blocked
? `This PR would increase monthly cloud spend by **$${delta}**, which exceeds the configured threshold of **$${threshold}/month**.\n\nThis PR is blocked from merging until a budget owner reviews and approves. Please:\n1. Ping \`@platform-team\` in Slack for a cost review\n2. Consider whether this infrastructure change can be right-sized\n3. If approved, a team lead can override by adding the \`cost-approved\` label`
: `This PR is within the configured budget threshold. Cost changes are acceptable.`}
*Powered by Infracost + GitHub Actions*`;
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
- name: Alert Slack if budget gate triggered
if: steps.cost-gate.outputs.blocked == 'true'
uses: slackapi/slack-github-action@v1
with:
channel-id: ${{ secrets.SLACK_CHANNEL_ID }}
payload: |
{
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": " Budget Gate Triggered" }
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Repository:* ${{ github.repository }}\n*PR:* #${{ github.event.pull_request.number }} — ${{ github.event.pull_request.title }}\n*Author:* ${{ github.event.pull_request.user.login }}\n\n*Cost delta: ${{ steps.cost-gate.outputs.cost_delta }}/month* exceeds threshold of ${{ steps.cost-gate.outputs.blocked }}/month"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Review PR" },
"url": "${{ github.event.pull_request.html_url }}",
"style": "danger"
}
]
}
]
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Fail if budget gate triggered
if: steps.cost-gate.outputs.blocked == 'true'
run: |
echo "Budget gate triggered. Monthly cost increase of $${{ steps.cost-gate.outputs.cost_delta }} exceeds the $${{ steps.cost-gate.outputs.threshold }} threshold."
echo "Add the 'cost-approved' label to override after budget review."
exit 1
Step 4: Configure the Budget Threshold
Set your threshold as a GitHub Actions Variable (not a secret, it's not sensitive):
GitHub → Settings → Variables → Actions → New repository variable
| Variable | Value |
|----|----|
| MONTHLY_BUDGET_THRESHOLD | 50 (or whatever your team agrees on) |
The right threshold depends on your team's infrastructure maturity. A good starting point: set it to the monthly cost of one db.t3.small RDS instance (~$50). Any PR that adds more than that to your bill needs a human review.
Step 5: Add the Cost-Approved Override Label
For cases where a cost increase is deliberate and justified, scaling up for a new product launch, adding a read replica for a high-traffic feature, you need an escape valve that doesn't require changing the pipeline.
Create a GitHub label called cost-approved. Then update the Fail if budget gate triggered step:
- name: Check for cost-approved override
id: check-override
uses: actions/github-script@v7
with:
script: |
const labels = context.payload.pull_request.labels.map(l => l.name);
const approved = labels.includes('cost-approved');
core.setOutput('approved', approved);
- name: Fail if budget gate triggered and not approved
if: steps.cost-gate.outputs.blocked == 'true' && steps.check-override.outputs.approved != 'true'
run: |
echo "Budget gate triggered. Add 'cost-approved' label to override."
exit 1
This way, an engineering lead can review a PR, agree the cost increase is justified, add the cost-approved label, and the pipeline clears, with a full audit trail in GitHub of who approved it and when.
What a PR Comment Looks Like
When a developer opens a PR that changes db.t3.micro to db.t3.small, they'll see this automatically posted to the PR:
Cloud Cost Gate: Within budget threshold
> Monthly cost delta: $14.60 (threshold: $50/month)
## Cost Breakdown
Resource | Monthly Cost | Change
----------------------------|-------------|--------
aws_db_instance.main | +$14.60 | ↑ db.t3.micro → db.t3.small
aws_ecs_service.app | $0.00 | No change
TOTAL | +$14.60/mo |
Powered by Infracost + GitHub Actions
And if the change pushes past the threshold:
Cloud Cost Gate: BUDGET GATE TRIGGERED
Monthly cost delta: $187.20 (threshold: $50/month)
This PR would increase monthly cloud spend by $187.20, which exceeds
the configured threshold of $50/month.
Please:
1. Ping @platform-team in Slack for a cost review
2. Consider whether this infrastructure change can be right-sized
3. If approved, a team lead can override by adding the cost-approved label
The engineer knows immediately what they've done, what it costs, and what to do next. That's the feedback loop that changes behaviour.
The Complete Pipeline: What You've Built Across Three Articles
Let's take a moment to look at what the full pipeline does now:
Developer pushes code / opens PR
↓
┌──────────────┬───────────────┬──────────────────┐
│ Lint │ Secret Scan │ Cost Gate │
│ (ESLint) │ (Gitleaks) │ (Infracost) │ ← parallel
└──────┬───────┴───────┬───────┴──────┬────────────┘
│ │ │
▼ ▼ ▼
Tests SAST Scan PR Comment +
(Jest) (Semgrep) Budget Gate
│
▼
Build → Docker image → ECR
│
▼
Container Scan (Trivy)
│
▼
AI Synthesis (GPT-4o) → Slack Incident Report
│
Block or Pass
│
▼
Deploy Staging ← automatic if all gates pass
│
Manual approval
│
▼
Deploy Production
Every gate has a purpose. Every failure produces actionable output. No gate is a dead end, there's either a fix path or an override path with an audit trail.
This is what a production-grade pipeline looks like. Not the most automated thing possible. The most useful thing possible.
Key Takeaways From the Series
Part 1 taught us: Pipeline structure matters more than individual steps. Fail fast. Gate production behind human approval. Tag images with git SHAs, not latest.
Part 2 taught us: Raw scanner output is not intelligence. AI synthesis turns findings into decisions. The agent advises; the engineer decides. Separate the tools that find problems from the layer that interprets them.
Part 3 taught us: Cost is a first-class engineering concern. Embed it where decisions are made, in the PR, at the moment of change, not in a monthly finance report. Give engineers an override path, but make the default behaviour cost-aware.
The pipeline we've built together isn't just a deployment mechanism. It's a feedback system. Every push, every PR, every deployment teaches your team something about the safety and cost profile of their changes, automatically, in context, at the moment it matters.
That's the goal. That's what production-grade means.
← Back to Part 2: Adding AI-Powered Security Scanning
← Back to Part 1: Setting Up a CI/CD Pipeline From Scratch
References
[1] Flexera. 2026 State of the Cloud Report. April 2026. https://info.flexera.com/CM-REPORT-State-of-the-Cloud
[2] Infracost. Infracost Documentation — GitHub Actions Integration. https://www.infracost.io/docs/integrations/github_actions/
[3] Infracost. Infracost Cloud Pricing API. https://www.infracost.io/docs/supported_resources/aws/
[4] HashiCorp. Terraform AWS Provider Documentation. https://registry.terraform.io/providers/hashicorp/aws/latest/docs
[5] GitHub. GitHub Actions — Variables and Secrets. https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions
[6] DORA. Accelerate State of DevOps Report 2024. https://dora.dev/research/2024/dora-report/
\
This content originally appeared on HackerNoon and was authored by emmanuela Opurum
emmanuela Opurum | Sciencx (2026-05-12T13:00:34+00:00) Building a Production-Grade CI/CD Pipeline — Part 3: Adding Cloud Cost Optimization. Retrieved from https://www.scien.cx/2026/05/12/building-a-production-grade-ci-cd-pipeline-part-3-adding-cloud-cost-optimization/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.