Terraform capacity analyzer / CLI / v0

Find the ceiling before production finds it.

headroom reads your Terraform plan, builds the resource graph, and tells you which resource runs out first and which one is sized far past anything it will ever serve.

 curl -fsSL https://headroomcli.com/install.sh | sh

Linux · macOS · Windows · Apache 2.0 Checksums and signed binaries Try it

No agent, no account. Two things open a socket: the upload you ask for, and a daily update check you can switch off.

headroom analyze fixtures/01-ecs-rds/plan.json R1 / CRITICAL

Drag the nodes

Scroll the board sideways

Resource graph drawn as circuit board traces aws_ecs_service.api opens 800 connections at full scale and sends them down a derived edge into aws_db_instance.main, whose ceiling is about 450. 450 are accepted and 350 overflow. The system saturates at 56 percent of the 40 tasks the plan authorises. CONSUMER aws_ecs_service.api 800conn PROVIDER aws_db_instance.main 450conn OVERFLOW 350 saturates at 56% 40 tasks

The edge nobody drew, and the number nobody wrote down.

This link is not in the Terraform dependency graph. It is derived from the database security group's ingress rule, which references the application's security group. Trace width is drawn in proportion to the connections it carries, the way a board sizes copper to current.

  • 800 opened by the service at full scale
  • 450 accepted by the instance class
  • 350 with nowhere to go

scale asymmetry

Every resource is correct. The sizes never talk to each other.

Something picked those numbers: a model that has never seen your traffic, a copied module, or you at the end of a long day. Each one is defensible on its own line. The bottleneck only exists between two of them, and nothing you run reads two lines at once.

  • sized too small

    03:40, the service scaled out exactly the way you told it to, and the app cannot open a connection. The database is sitting at 12% CPU. It was never CPU: 40 tasks at a pool of 20 is 800 connections into an instance class that accepts 450.

  • sized too large

    A db.r5.2xlarge in front of a service that will never open more than 60 connections. It passed review because oversizing has never woken anybody up, and it bills every hour for a ceiling nobody is going to reach.

  • the one that is not compute

    A /28 subnet offers 11 usable addresses and the autoscaler is authorised for 40 awsvpc tasks. Placement starts failing at 55% of that, and the error names the subnet, never the line that sized it.

  1. terraform validate

    Passes. Every resource is correct in isolation.

  2. terraform apply

    Succeeds. Nothing in a plan compares one size against another size.

  3. traffic

    Arrives. The first size to run out decides how far the system actually scales, and you find out in production.

what headroom does about it

It reads two lines at once, and puts a number between them.

headroom takes the plan file terraform already prints, builds the resource graph, and compares every consumer against the ceiling of the provider it depends on. You get the resource that runs out first, the percentage of your own authorised scale where that happens, and the resources whose ceiling is so far above the demand that you are paying for headroom you will never use. Before the apply, in the same pull request that introduced it.

Infracost tells you the price. Spacelift tells you the run passed. Vantage tells you the bill went up. The ceiling is the one number none of them carry.

why this one

Four things it does that your pipeline currently does not.

  • It gives you the ceiling, not the bill.

    Cost tools price what you provisioned and CI tools confirm the run passed, which leaves the limit itself unmeasured. You get the number the resource actually stops at, so you size it in a pull request instead of during an incident.

  • It finds the edges Terraform never drew.

    The link that broke the fixture below is not in the dependency graph at all: it is derived from a security group ingress rule pointing at another security group. That is exactly the class of coupling a review misses, because no single file contains it.

  • Every ceiling carries a source and a date.

    Each catalog entry ships with the vendor document it came from, the day it was verified and a confidence level, and a rule that cannot ground its numbers stays silent and tells you what it skipped. You can defend the number in an architecture review, and you are never arguing with a guess.

  • One static binary that reports nothing about you.

    It parses on your machine, reads only an allowlist of capacity attributes, and carries one dependency that has none of its own. It drops into CI as a single line and clears a security review without an exception.

headroom analyze

Real output, from a fixture that plans without an account.

fixtures/01-ecs-rds is real Terraform shaped like what a model returns for “give me an ECS service on Fargate with a postgres database”. The plan applies cleanly, every resource is correct in isolation, and the run below is the unedited output.

CRITICAL R1 Scale asymmetry: application outgrows the database
  At full scale the workloads in front of aws_db_instance.main open ~800
  connections against a ceiling of ~450. Saturation lands at 56% of the scale
  this plan already authorises.

    - aws_ecs_service.api scales to 40 tasks (max_capacity of
      aws_appautoscaling_target.api) x 20 connections per task (DB_POOL_SIZE=20
      in aws_ecs_task_definition.api) = 800 connections
    - aws_db_instance.main (db.t3.medium, postgres) accepts ~450 connections by
      default [LEAST({DBInstanceClassMemory/9531392}, 5000)]

  confidence: high  |  source: docs.aws.amazon.com/AmazonRDS/.../CHAP_Limits.html
CRITICAL R2 Subnet runs out of addresses before the service stops scaling
  aws_subnet.private_a (10.0.1.0/28) offers 11 usable addresses and the
  workloads placed in it need ~20 at full scale. Task placement starts failing
  at 55% of the authorised scale.

Two independent ceilings, two different percentages, one plan that passes every check it is given.

terraform show -json

Three steps, and you already run two of them.

There is nothing to install in the cluster, nothing to grant and nothing to sign up for. headroom reads the JSON that terraform already knows how to print, so the first real answer is about a minute away.

  1. Install the binary

    One line, or a download you verify against the published checksums. It is a single static binary, so there is no runtime to install first and nothing to configure before the first run.

    curl -fsSL https://headroomcli.com/install.sh | sh
  2. Print the plan you already make

    terraform plan then terraform show -json, the two commands your pipeline runs anyway. The JSON stays on your disk: headroom reads the file, it does not connect to your account or to your cloud.

    terraform plan -out=tfplan terraform show -json tfplan > plan.json
  3. Run it, and read the ceilings

    headroom analyze plan.json prints every resource that runs out first, with the number, the source behind it and the percentage of your authorised scale. Add --fail-on critical and the same command becomes the whole CI gate.

    headroom analyze plan.json

Flags worth knowing

--json
Findings as JSON, for a CI gate or a dashboard you already own.
--dry-run
Print the exact redacted payload that would be uploaded, and upload nothing.
--fail-on critical
Exit 1 on critical, or on warning and worse. That is the whole CI integration.
--pool-size N
Connections per task to assume when the task definition does not declare one.
--warn-at R
Utilization ratio that triggers a warning. Default 0.8.
--salt S
Per organization salt used to hash resource addresses.
--no-update-check
Silence the once a day check for a newer release. HEADROOM_NO_UPDATE_CHECK=1 does the same.

A headroom.yaml at the root of the repository is picked up automatically: tune a rule, state a fact about your account that no plan can state, or silence a finding with a mandatory reason and an expiry date, so the finding comes back on the day the reason ran out.

internal/rules

Twenty rules, three clouds, every one of them grounded.

Provider coverage is uneven on purpose. Each cloud ships the rules whose ceilings are actually published, and a rule with no source behind it does not ship at all.

AWS 8

  • R1Database connections: max_tasks x pool_size against max_connections(instance_class, engine).
  • R2Subnet address exhaustion: awsvpc tasks against the usable IPs in the CIDR.
  • R3SQS and Lambda: visibility against timeout, poller starvation, and the drain rate a concurrency cap implies.
  • R4Egress concentration: subnets, zones and workloads behind one NAT gateway.
  • R5EBS: the gp2 burst credit cliff, and the gp3 ratios AWS refuses.
  • R6Asymmetric autoscaling: consumer scales, provider is fixed, storage that cannot grow.
  • R7Burstable CPU: baseline vCPUs, and whether credits throttle or bill.
  • R8VPN connections on a virtual private gateway do not aggregate.

Azure 6

  • AZ1Flexible Server connections, reported as max_user_connections rather than the headline number.
  • AZ2AKS node subnet exhaustion: Azure CNI reserves one address per pod, up front, per node.
  • AZ3NAT gateway SNAT port exhaustion, with pods as the divisor.
  • AZ4VPN gateway SKU throughput is shared, and connections do not add to it.
  • AZ5Managed disk tier against what the VM size can actually drive.
  • AZ6B series credits, which throttle rather than bill. Azure has no unlimited mode.

Google Cloud 6

  • GC1Cloud SQL connections, using the database_flags override when the plan declares one.
  • GC2GKE addresses across three ranges: pod secondary, node primary and services.
  • GC3Cloud NAT port allocation against the VMs behind the gateway.
  • GC4Persistent disk performance derived from size.
  • GC5Serverless VPC connector throughput against what sits behind it.
  • GC6Cloud SQL storage frozen or capped while the tier in front of it scales.

A rule that cannot ground its numbers stays silent and says what it skipped. A wrong number in a capacity report costs the whole customer, so an absent ceiling always beats an uncertain one.

R4 never reports critical. A single NAT gateway is usually a deliberate cost decision, and the job is to make the trade visible, not to overrule it.

--dry-run

Your plan file never leaves your machine.

Extraction is by allowlist, never denylist.

Per resource type there is a closed list of the attributes that matter for capacity. Anything not on that list is never read, so it cannot leak. A denylist leaks the first time a provider adds an attribute nobody anticipated.

Read, because a ceiling needs it

  • instance_class
  • engine
  • max_capacity
  • cidr_block
  • volume_type
  • desired_count

A short list per resource type, held in the extractors under internal/extract/.

Never touched

  • container_definitions
  • user_data
  • tags
  • password
  • policy
  • everything else, by default

container_definitions is excluded deliberately. Pool size is derived from it locally and only the resulting integer ever leaves.

  • The CLI parses locally. The plan file is read on your disk and stays there.
  • Resource addresses are hashed with a per organization salt. Real names exist only on your machine, and the local report reattaches them.
  • Finding text is not uploaded either. A rule id, a severity and the bare numbers travel, and the sentence is rebuilt on the other side.
  • headroom analyze --dry-run prints the exact payload, and it is the only thing that ever carries your data off the machine.
  • Two things open a socket. The upload you ask for, and a once a day check for a newer release that sends no query string, no identifier and no version, and stops with --no-update-check.

the entire dependency tree

module github.com/headroom-project/headroom

go 1.26.6

require gopkg.in/yaml.v3 v3.0.1

One dependency, and it has none of its own. Everything else is the standard library, because this runs inside customer environments and every transitive dependency is supply chain surface that somebody has to defend during a security review.

internal/catalog/data

Parsing a plan file is a weekend. The catalog is the part that takes years.

What nobody has is the ceiling table: given a resource in a given configuration, what its real limit is. That number is almost never in the Terraform. db.t3.medium does not say “450 connections” anywhere in the state. It comes from a parameter group formula over the instance class memory, and the formula differs between MySQL, MariaDB and PostgreSQL.

one entry, in full json
{
  "ceiling": "max_connections",
  "formula": "LEAST({DBInstanceClassMemory/9531392}, 5000)",
  "source": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Limits.html",
  "verified_at": "2026-08-14",
  "confidence": "high"
}

Every entry carries source, verified_at and confidence. No entry ships without them. It is plain JSON on purpose: the knowledge has to survive any rewrite of the code around it.

Every number in the catalog was verified against primary vendor documentation on 2026-08-14. That pass found and fixed two of our own wrong ceilings. The discipline is the product.

free / premium / enterprise

Free is the whole CLI. Paid is only the part that needs a server.

One repository fits in a binary. Joining ten of them needs a server, an account, an identity and history. What is paid is exactly what needs a server, not a good feature locked artificially.

  • Free

    $0

    Open source, on GitHub today

    • The full CLI: 20 rules across AWS, Azure and Google Cloud, with the catalog embedded in the binary
    • headroom.yaml for tuning, exceptions and your own policy rules
    • JSON output, --dry-run, and a CI gate with --fail-on
    • Apache 2.0, patent grant included, no usage limit and no account
    Download the open source CLI
  • for a team

    Premium

    $10per month

    Up to 3 seats and 1 organisation

    • Everything in Free, for every member of the team
    • Web dashboard with history and trend, so a ceiling moving toward you is visible before it arrives
    • Automatic pull request comments through the GitHub App
    • Findings kept per organisation, with resource names hashed by your own salt
    Coming, not available yet

    No lock in

  • Enterprise

    On request

    More seats, more than one organisation

    • Everything in Premium, without the seat and organisation limits
    • Cross repository macro analysis, which is where a shared ceiling actually shows up
    • Continuous AWS, Azure and Google Cloud collectors
    • A say in which rules get built next, and in the order they arrive
    Talk to us

    No lock in

Only the Free column exists today. Premium and Enterprise are being built, the price is what they will launch at, and nothing on this page takes a payment yet.

install.sh

Install

A single static binary. Nothing to configure before the first run.

Language
Go, one static binary
Dependencies
one, and it has none of its own gopkg.in/yaml.v3
License
Apache 2.0, patent grant included
Clouds
AWS, Azure and Google Cloud
 curl -fsSL https://headroomcli.com/install.sh | sh

Or take the path you can verify

go install github.com/headroom-project/headroom@latest

# or download and verify
curl -LO https://github.com/headroom-project/headroom/releases/latest/download/headroom_linux_amd64.tar.gz
curl -LO https://github.com/headroom-project/headroom/releases/latest/download/checksums.txt
sha256sum -c checksums.txt

Direct downloads

Every release ships checksums.txt next to the archives. Verify before you run it, the same way you would want your own users to.

The ceiling is already there. The only question is who finds it first.

One command against a plan file you already produce, and you know the answer before your users do.

Download the open source CLI

Apache 2.0, one static binary, no account and no telemetry.