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.00 against a named resourceinfracost breakdown lists the resource but prices it at zero. The plan JSON contains null or (known after apply) for an attribute Infracost needs (instance class, allocated storage, IOPS), so the price term collapses. Common on PostGIS-enabled aws_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 values were resolved.
  • Total far below reality — the run completes but undercounts. Usually a region fallback (the estimator silently defaults to us-east-1 rates) 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.

Infracost zero-cost triage decision tree Starting from an Infracost run reporting $0.00 or a suspiciously low total, three branches separate the signals. A resource priced at 0.00 means unresolved values in the plan JSON, fixed by re-serializing a fully resolved plan and adding a usage file. A resource missing entirely means an unsupported type or pre-resolution serialization, fixed by a usage mapping or a corrected plan export. A total far below reality means a region fallback or unmodeled tile-pyramid egress, fixed by pinning the region and modeling egress as a usage input. Infracost run reports $0.00 or a low total Resource priced 0.00 listed, but quantity unresolved Resource missing never appears in breakdown Total far below reality run completes, undercounts Cause: null / known-after- apply in plan JSON Cause: unsupported type or pre-resolution serialize Cause: region fallback or unmodeled tile egress Fix → Step 1 + 3 re-serialize resolved plan, add usage file Fix → Step 3 + 4 add usage mapping or fix plan/Pulumi export Fix → Step 2 + 3 pin region, model tile egress as usage input
# 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 valid INFRACOST_API_KEY injected from the CI secret store (never committed).
  • Terraform >= 1.10 with the aws provider pinned, e.g. version = "~> 5.60". For Pulumi stacks, the AWS provider plugin pinned in package.json / Pulumi.yaml, because Infracost does not consume pulumi preview --json directly — 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:GetObject plus dynamodb:GetItem for 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.

  1. Serialize a fully resolved plan. Infracost prices the plan JSON, not the binary. Generate it against the locked backend so every values object is resolved — unknown attributes are the primary source of $0.00:
    terraform init -input=false           # against the real remote backend
    terraform plan -out=tfplan.binary -input=false
    terraform show -json tfplan.binary > tfplan.json
    Geospatial relevance: PostGIS storage and instance-class attributes are frequently (known after apply) until dependencies resolve; a premature serialization prices the database tier at zero.
  2. 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_REGION or the provider region).
  3. 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.yml so the estimator has the inputs the plan does not carry:
    # 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
    Run the breakdown with the usage file attached:
    infracost breakdown --path tfplan.json \
      --usage-file infracost-usage.yml \
      --format table
    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.
  4. 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 lazy Outputs) 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.
  5. Recover from failed to load state / empty plan. If plan generation itself fails — lock contention, rotated credentials, or a corrupted cache — purge .terraform/.pulumi cache 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, or WorkloadType tags prices at 0.00. This turns “the gate was green but blind” into a hard failure.
  • Version the usage file with the modules. Keep infracost-usage.yml in 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 (gp2gp3) 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.