Cost Tracking Spatial Infrastructure with Infracost: Zero-Cost Diagnostics & State Recovery
When an Infracost run prices a geospatial estate at $0.00 — or silently omits a PostGIS instance, a GPU rendering fleet, or a tile-serving cache — the cost gate stops protecting the budget while still reporting green. This guide diagnoses the exact failure, recovers the plan and state inputs Infracost depends on, and re-prices the stack correctly. It sits inside the Cost Estimation Frameworks discipline and the wider Spatial IaC Architecture & Fundamentals practice, where pricing telemetry has to behave like a build-blocking pipeline component rather than a retrospective audit. The symptoms below are specific to spatial workloads, where non-standard compute profiles and custom modules routinely fall outside Infracost’s default resource-type mappings.
Symptom identification and triage
The defining signal is a breakdown that returns $0.00 (or a suspiciously low total) for resources you know are billable. Three observable patterns separate the root causes:
0.00against a named resource —infracost breakdownlists the resource but prices it at zero. The plan JSON containsnullor(known after apply)for an attribute Infracost needs (instance class, allocated storage, IOPS), so the price term collapses. Common on PostGIS-enabledaws_rds_cluster, GPU rendering nodes, and Elasticsearch-backed geocoding fleets.- Resource missing entirely — the resource never appears in the breakdown. Either the resource type is unsupported and has no usage mapping, or the plan was serialized before
valueswere resolved. - Total far below reality — the run completes but undercounts. Usually a region fallback (the estimator silently defaults to
us-east-1rates) or a tile-pyramid egress line that was never modeled as a usage input.
A useful triage move is to confirm whether the price term is genuinely zero or simply unknown. For any priced resource the estimator computes a quantity times a unit rate; when the quantity is unresolved it is treated as zero:
$$C_{\text{resource}} = q \times r_{\text{unit}}, \qquad q = \text{unknown} ;\Rightarrow; C_{\text{resource}} \to 0$$
So a 0.00 line is almost always an unresolved-quantity problem, not a free resource. Triage by reading the plan JSON before blaming Infracost.
# Is the value resolved, or unknown? Inspect the plan JSON directly.
terraform show -json tfplan.binary \
| jq '.planned_values.root_module.resources[]
| select(.type=="aws_db_instance")
| {address, instance_class: .values.instance_class,
storage: .values.allocated_storage}'
# null / absent fields here are exactly what Infracost prices at $0.00.
Prerequisites and environment assumptions
This guide assumes the following toolchain and access scope. Pin every component so a provider or CLI upgrade cannot silently change default sizing and move the estimate:
- Infracost CLI
v0.10+with a validINFRACOST_API_KEYinjected from the CI secret store (never committed). - Terraform
>= 1.10with theawsprovider pinned, e.g.version = "~> 5.60". For Pulumi stacks, the AWS provider plugin pinned inpackage.json/Pulumi.yaml, because Infracost does not consumepulumi preview --jsondirectly — it prices a Terraform-format plan derived from the stack. - Backend state reachable and consistent: the runner must read the same locked remote backend the deployment uses, via the patterns in State Backend Selection. Pricing a diff against a stale local snapshot produces phantom changes.
- Read-only state IAM for the CI runner —
s3:GetObjectplusdynamodb:GetItemfor lock metadata, and nothing more. The cost stage never needs apply rights; scoping it down keeps the blast radius small and mirrors IAM Role Mapping for GIS.
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::spatial-infra-state",
"arn:aws:s3:::spatial-infra-state/*"] },
{ "Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:DescribeTable"],
"Resource": "arn:aws:dynamodb:us-west-2:123456789012:table/spatial-state-lock" }
]
}
Step-by-step remediation
Work the steps in order; each one resolves a distinct cause of the zero-cost signal.
- Serialize a fully resolved plan. Infracost prices the plan JSON, not the binary. Generate it against the locked backend so every
valuesobject is resolved — unknown attributes are the primary source of$0.00:
Geospatial relevance: PostGIS storage and instance-class attributes are frequentlyterraform init -input=false # against the real remote backend terraform plan -out=tfplan.binary -input=false terraform show -json tfplan.binary > tfplan.json(known after apply)until dependencies resolve; a premature serialization prices the database tier at zero. - Pin the region. An unset region makes the estimator fall back to
us-east-1, under-pricing higher-cost regions and all inter-region replication. Set it explicitly in the plan context (AWS_DEFAULT_REGIONor the providerregion). - Map unsupported and usage-based resources with a usage file. This is the core fix for spatial resources Infracost cannot price from the plan alone. Declare the missing quantities — request volume, monthly tile egress, GPU node hours — in
infracost.ymlso the estimator has the inputs the plan does not carry:
Run the breakdown with the usage file attached:# infracost-usage.yml — supplies quantities the plan JSON cannot resolve version: 0.1 resource_usage: aws_db_instance.postgis: monthly_standard_io_requests: 90000000 # spatial index scans dominate IO aws_s3_bucket.raster_tiles: standard: storage_gb: 8000 monthly_tier_1_requests: 50000000 # PUTs during pyramid warm-up monthly_data_transfer_out_gb: 12000 # tile-pyramid egress, modeled explicitly
Geospatial relevance: tile-pyramid egress grows geometrically with zoom depth, so it must be a priced input — not a constant — or the CDN line is undercounted by orders of magnitude.infracost breakdown --path tfplan.json \ --usage-file infracost-usage.yml \ --format table - Bridge Pulumi stacks correctly. Because Infracost does not read
pulumi preview --json, price a Terraform representation of the stack and ensure instance attributes are emitted as resolved literals (not lazyOutputs) so they survive serialization. The provisioning detail for this lives in Module Design Patterns; the engine trade-off is covered in Terraform vs Pulumi for GIS. - Recover from
failed to load state/ empty plan. If plan generation itself fails — lock contention, rotated credentials, or a corrupted cache — purge.terraform/.pulumicache directories, re-init with explicit backend config, and confirm the lock is released before re-running. Never bypass the audit trail: state intervention stays behind pull-request review, in line with Managing Terraform State Locks for Spatial Data.
Verification
Confirm the fix produced a real, non-zero price for every billable spatial tier before trusting the gate again:
# Every billable resource should now carry a positive monthlyCost.
infracost breakdown --path tfplan.json --usage-file infracost-usage.yml \
--format json --out-file cost.json
jq -r '.projects[].breakdown.resources[]
| select((.monthlyCost // "0") | tonumber == 0)
| .name' cost.json
# An empty result = no resource is silently priced at $0.00.
Cross-check the total against the previous committed baseline with infracost diff --path tfplan.json --compare-to infracost-base.json: the PR comment should now attribute the change to specific resources (for example “+$380/month, two db.r6g.xlarge read replicas”) rather than reporting a flat zero. Finally, after apply, reconcile realized spend against the estimate; a large gap on the tile-serving tier signals an egress assumption that still needs tuning, not a rounding error.
Preventing recurrence
Encode the fix so the zero-cost failure cannot reappear silently:
- Fail the build on a zero-priced billable resource. Add a policy-as-code check (OPA/Rego or Sentinel) that reads the Infracost JSON and rejects the merge if any resource carrying the mandatory
CostCenter,Environment, orWorkloadTypetags prices at0.00. This turns “the gate was green but blind” into a hard failure. - Version the usage file with the modules. Keep
infracost-usage.ymlin the same repository as the spatial modules so pyramid depth and request-volume assumptions are reviewed alongside the resources they price. - Pin region and provider versions in CI. A provider bump that flips a storage default (
gp2→gp3) changes both the IOPS profile and the per-GB rate; pinning keeps the baseline diffable. - Gate the diff, not the absolute. Run the breakdown on every pull request and block on the incremental cost, so a new untagged or unpriced resource is caught at review time.
# .github/workflows/spatial-cost-gate.yml — fails on zero-priced billable resources
- name: Convert plan to JSON
run: terraform show -json tfplan.binary > tfplan.json
- name: Infracost breakdown (with usage file)
run: |
infracost breakdown --path tfplan.json \
--usage-file infracost-usage.yml \
--format json --out-file cost.json
env:
INFRACOST_API_KEY: ${{ secrets.INFRACOST_API_KEY }}
AWS_DEFAULT_REGION: ${{ vars.GIS_REGION }} # never fall back to us-east-1
- name: Reject silent zero-cost
run: |
ZEROES=$(jq -r '[.projects[].breakdown.resources[]
| select((.monthlyCost // "0") | tonumber == 0)] | length' cost.json)
if [ "$ZEROES" -gt 0 ]; then
echo "::error::Billable resource priced at \$0.00 — usage mapping missing."
exit 1
fi
Never commit tfplan.json or Pulumi preview output to version control; store them as ephemeral CI artifacts with strict retention, and route billing/pricing API calls through VPC endpoints so cost telemetry — which encodes the shape of your infrastructure — does not traverse the public internet.
Related
- Cost Estimation Frameworks — the parent discipline: cost gates, tag-based allocation, and the egress model this page feeds.
- Managing Terraform State Locks for Spatial Data — recovering the consistent state reads accurate pricing depends on.
- Pulumi IAM Policies for S3 Raster Access — least-privilege patterns that also apply to the read-only cost runner.
- Terraform vs Pulumi for GIS — why Pulumi stacks need a Terraform plan bridge before estimation.
- Spatial IaC Architecture & Fundamentals — the practice this troubleshooting guide plugs into.