headroom
GitHub Install

Terraform capacity analyzer / CLI / v0

What does this Terraform hit before it breaks?

Infracost tells you the price. Spacelift tells you the run passed. Vantage tells you the bill went up. Nobody tells you the ceiling.

headroom reads a plan file, builds the resource graph, and puts a number on the edge where the consumer scales and the provider does not.

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, Google Cloud
one line install sh
 curl -fsSL https://headroomcli.com/install.sh | sh
Checksums and signed binaries

No agent, no account, no network calls unless you ask it to upload.

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

Scroll the board sideways

Resource graph drawn as circuit board traces aws_ecs_service.api scales to 40 tasks at 20 connections each, which sends 800 connections down a derived edge into aws_db_instance.main, a db.t3.medium postgres instance whose ceiling is about 450 connections. 450 are accepted and 350 overflow. Saturation lands at 22.5 tasks, 56 percent of the 40 tasks the plan authorises. aws_appautoscaling_target.api max_capacity = 40 aws_ecs_task_definition.api DB_POOL_SIZE = 20 CONSUMER aws_ecs_service.api 40 tasks x 20 conn = 800 connections PROVIDER aws_db_instance.main db.t3.medium / postgres ceiling ~450 conn 800 connections 450 DERIVED EDGE sg.db ingress references sg.app not in the terraform dependency graph OVERFLOW 350 over ceiling AUTHORISED SCALE saturation at 22.5 tasks / 56% 0 10 20 30 40 tasks

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

The link between the service and the database 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 the trace 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

450 connections divided by 20 per task is 22.5 tasks. The plan authorises 40. The system saturates at 56% of the scale it was already allowed to reach.

scale asymmetry

Every rule is the same sentence wearing different clothes.

The consumer scales and the provider does not. Generated Terraform gets this wrong because a model has no idea what the traffic looks like, so it takes the default, and the default is the bottleneck.

  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.

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.

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. The sizes never talk to each other.

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 commands. You already run two of them.

There is nothing to install in the cluster and nothing to grant. headroom reads the JSON that terraform already knows how to print.

usage bash
 terraform plan -out=tfplan
 terraform show -json tfplan > plan.json
 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.

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. There is no second channel.
  • No network calls at all unless you ask it to upload.

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 / subscription

Free is the whole CLI. Paid is 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.

Comparison of what is free today and what is planned for the subscription
Capability Free, open source available today Subscription coming, not available yet
Full CLI, every rule, embedded catalog yes
headroom.yaml: tuning, exceptions, custom policy rules yes
--dry-run, JSON output, CI gate yes
Web dashboard, history, trend coming
Cross repository macro analysis (workspace) coming
Automatic pull request comments, GitHub App coming
Continuous AWS, Azure and GCP collectors coming

Nothing in the right column exists yet. There is no waitlist and no price to quote. The left column is on GitHub today.

install.sh

Install

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

quickest path sh
 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.