Know what your Terraform costs before you apply it.
Open source cost estimation for Terraform, OpenTofu, Terragrunt and CloudFormation. No API key, no SaaS account, no telemetry.
Documentation Β· Quickstart Β· CI/CD Β· Resource catalog Β· Blog
c3x parses your infrastructure code statically. No terraform init, no
providers, no cloud credentials, no state access, so it is fast enough to
sit in front of every pull request.
For input you don't control, such as pull requests from forks, use
untrusted-input mode: --no-remote-modules, or C3X_NO_REMOTE_MODULES=true
for every command. The GitHub Action turns it on automatically for pull
requests from forks. In that mode c3x fetches no remote modules, reads no
files, keeps local modules inside the scanned directory, and ignores
anything in the repository's .c3x.toml that could redirect pricing,
credentials or file access (only region, currency, format, budgets and an
in-project usage files are honoured). Every parse is also bounded β at most
10,000 instances per resource, 200,000 resources, 5,000 module expansions
and two minutes β so a hostile configuration fails fast instead of
exhausting the machine. Those limits are checked between resources and
modules; a single expression, such as nested for comprehensions, is
evaluated to completion and can still use significant CPU and memory. If
you run c3x as a service on untrusted input, give it container memory and
CPU limits and a wall-clock timeout as well.
| Method | Command |
|---|---|
| Homebrew | brew install c3xdev/tap/c3x |
| Script | curl -fsSL https://c3x.dev/install.sh | sh |
| Docker | docker pull ghcr.io/c3xdev/c3x |
| Go | go install github.com/c3xdev/c3x/cmd/c3x@latest |
Windows, and anyone who would rather not pipe a script into a shell, can
take the archive straight from the
releases page, which carries
macOS, Linux and Windows builds alongside checksums.txt. The container
image is on GHCR.
The script detects your OS and architecture and checks the published
SHA-256, aborting on a mismatch. It skips that check if it cannot fetch
checksums.txt or finds no sha256 tool, so verify the download yourself
if you need the guarantee.
Verify the install with c3x doctor, which checks the catalog parses,
the pricing API answers, and the cache directory is writable.
Point c3x at a Terraform plan and it comments the cost change on the PR. The summary leads with the dollar delta, and the per-resource breakdown collapses so a large estimate does not flood the thread.
c3x comment github --path plan.jsonMonthly cost increased by $314.82 (+30.3%) π
| Baseline | New | Change |
|---|---|---|
| $1038.32/mo | $1353.14/mo | πΊ +$314.82 |
Estimate details
| Resource | Baseline | Current | Ξ |
|---|---|---|---|
aws_instance.web |
$285.32 | $565.64 | πΊ +$280.32 |
aws_db_instance.main |
$753 | $787.5 | πΊ +$34.5 |
The delta comes from the plan itself. A plan carries both the prior and the planned state of every resource, so there is no baseline file to manage and no base-branch checkout. Works on GitHub, GitLab, Bitbucket and Azure DevOps, and auto-detects the repository and PR number in CI.
| Flag | What it does |
|---|---|
--comment-tag |
Keeps one comment per environment or directory on the same PR |
--recreate |
Posts a fresh comment at the bottom instead of editing in place |
--expand |
Posts the breakdown expanded instead of collapsed |
--baseline |
Diffs against a saved baseline rather than the plan's prior state |
The GitHub Action is on the Marketplace as C3X Cost Estimation:
name: Cost estimate
on: [pull_request]
permissions:
contents: read
pull-requests: write # post the comment
id-token: write # optional: comment as c3x-cloud[bot]
jobs:
c3x:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: c3xdev/c3x@v0
with:
path: .
budget-delta: "50" # fail the check if this PR adds more than $50/mo@v0 follows every release. To have comments posted as c3x-cloud[bot]
instead of github-actions, install the
C3X Cloud app on the repository and
keep id-token: write. The Action then fetches a short-lived token scoped
to that one repository; without the app or the permission it posts as
github-actions. Set branded-comments: false to never request one.
The same gates work from the CLI: --budget caps the absolute monthly cost
and --budget-delta caps the increase a single change may introduce. Both
exit non-zero, so a pull request that blows the budget fails the build.
If your pipeline already runs terraform plan, give c3x the plan.
A directory is evaluated statically, with no cloud credentials. That is
convenient, but values Terraform only knows at plan time are estimated:
data sources, module outputs computed from them, and some counts. Those
lines are marked unresolved_attribute, and a resource whose count or
for_each rests on a data source (one NAT gateway per availability zone)
is marked assumed_count. The plan has the real values:
- run: terraform plan -out=tfplan && terraform show -json tfplan > plan.json
- uses: c3xdev/c3x@v0
with:
path: plan.json
budget-delta: "50" # the plan's prior state is the baselineSee the CI/CD guide for GitLab CI, Bitbucket Pipelines, Azure Pipelines, Atlantis and Spacelift recipes.
| Command | What it does |
|---|---|
c3x estimate |
Monthly cost of a directory, .tf file, or plan JSON |
c3x diff |
Cost change against a saved baseline, with a delta gate for CI |
c3x top |
Ranks resources by cost. --format targets emits -target= flags for a teardown |
c3x comment |
Posts the estimate to a pull or merge request |
c3x recommend |
Suggests savings: right-sizing, cheaper families, unused resources |
c3x policy eval |
Evaluates cost policies written in Rego |
c3x pricing sync |
Warms the local price cache for fully offline runs |
c3x usage sync |
Writes measured usage (GB stored, ...) from your cloud account's metrics; see Usage |
c3x doctor |
Pre-flight checks, usable as a CI gate |
c3x supported-resources |
Lists every supported resource kind |
Full flag reference: c3x.dev/docs/cli.
Requests, GB stored and similar usage-driven lines are $0 until someone
writes a usage file. c3x usage sync measures them instead:
terraform show -json > state.json
c3x usage sync --state state.json # writes c3x-usage.synced.yml
c3x estimate # reads it, no flags neededpricing sync is not usage sync. c3x pricing sync warms the cache
of prices (rates). c3x usage sync fetches quantities from your own
account. Estimates never call a cloud API: only usage sync does, run by
you, with your credentials, making read-only calls. It refuses to run in
untrusted-input mode (--no-remote-modules), so a pull request from a fork
cannot trigger calls with the runner's credentials. Credentials come from
the AWS SDK's default chain (environment, shared config, SSO, instance or
task role); c3x doctor checks them once a synced file is in use.
It writes only the generated file, replacing it whole on every run, and
never reads or writes your hand-written usage_path file, so a re-sync
cannot overwrite an edit of yours. Pricing commands read both, and yours
wins per resource and per key (see Configuration). Values it cannot
establish are skipped and listed, in the output and under errors: in the
file; --strict makes that an error exit. A run that measures nothing at
all (no credentials, no --state, a missing permission) fails and leaves
the existing file as it was, rather than replacing it with an empty one.
Today: AWS S3 bucket storage (standard_storage_gb, from CloudWatch's
daily BucketSizeBytes). It needs cloudwatch:GetMetricData, which AWS
bills per metric requested. The real bucket name comes from --state;
without it a literal name in the configuration is used. S3 requests,
Lambda, NAT gateway, CloudFront and GCP follow; see
the design and ROADMAP section K.
c3x prices at public on-demand list rates, in USD, before any discount (reserved instances, savings plans, committed use, negotiated rates). For compute and managed databases in the provider's main region, expect estimates within a few percent of the list price; treat them as an upper bound on what a team with commitments pays.
When part of an estimate rests on an assumption, c3x says so on the line and in the total, marked β , in every output format including the PR comment:
| Caveat | Meaning |
|---|---|
region_fallback |
No price for your region under the catalog's filters, so the reference region's (us-east-1, eastus, us-central1) is shown |
no_price |
The lookup matched nothing, so a non-free resource shows $0 |
usage_not_provided |
A usage-driven cost (requests, GB processed, LCUs) is $0 because no usage was given; supply it with --usage, or measure it with c3x usage sync |
unresolved_attribute |
An attribute the price depends on could not be evaluated statically, so a default was used; a plan JSON input avoids this |
assumed_count |
The resource's count or for_each (or its module's) is computed from a data source c3x cannot query, such as data.aws_availability_zones, so the number of instances rests on a placeholder (three zones in the provider's region); the caveat names the value assumed, and a plan JSON input gives the exact count |
stale_price |
The pricing API was unreachable, so a cached price past its freshness window was used |
--strict makes any of these fail the run with exit code 3, distinct
from a budget breach (1), so a pipeline can require an estimate with no
assumptions in it. JSON output carries caveat_count and per-line
caveats.
c3x reads OpenTofu projects directly. .tofu files are loaded alongside
.tf, and a main.tofu replaces the main.tf of the same name, as
OpenTofu does, so a codebase that ships both is not counted twice.
JSON-syntax files (.tf.json, .tofu.json) and override files
(override.tf, *_override.tf, and their .tofu and JSON forms) are
read too, with override files merged into the blocks they override.
Provider for_each is supported: each instance of a resource is priced
in the region of the provider instance it uses. Plan JSON from
tofu show -json works the same as Terraform's.
file(), templatefile(), fileset() and the other filesystem
functions are off by default, because on a pull request from a fork
the configuration is untrusted, and a file read could leak something an
earlier CI step wrote into the workspace. Configurations that load
settings from files, such as yamldecode(file("${path.module}/fleet.yaml")),
get a warning naming the function instead of a silently wrong estimate.
For your own repository, turn them on with --allow-file-functions or
C3X_ALLOW_FILE_FUNCTIONS=true. Reads stay inside the project directory,
with symlinks resolved first. The setting is not read from a project's
.c3x.toml, since a pull request can edit that file, and
--no-remote-modules always turns it off.
text, markdown, json, junit, html, csv and sarif. JUnit feeds
CI dashboards, SARIF feeds code-scanning surfaces, and JSON is the
machine-readable contract:
c3x estimate --format json | jq .project_total1,340 recognized resource kinds across AWS, Azure and Google Cloud,
served from pricing.c3x.dev. Of those, 263 carry
a price and the rest are legitimately free. At estimate time c3x loads the
catalog remote-first: the live /catalog bundle, then the on-disk cache,
then an embedded snapshot for fully offline use. Every kind is classified:
| Class | Meaning |
|---|---|
| live | Every dimension prices from the upstream API and tracks vendor price changes |
| static | Inline-rate fallback for products the upstream does not expose cleanly |
| free | Legitimately free, such as public ACM certificates |
| zero / errored | A hard CI failure if non-empty |
Regenerate the health report against the live API with
go run ./cmd/verify_catalog, which prints the current classification
counts and fails on anything unpriced.
| Workload | Time |
|---|---|
| Parse a 500-resource monorepo (vars, locals, count, for_each, modules) | 10ms (Apple M3 Pro) |
| Cold estimate against pricing.c3x.dev | 3 to 5 seconds (network-bound) |
| Warm estimate (SQLite cache hit) | 35ms |
From internal/parser/terraform/bench_test.go. CI runs the benchmark on
every push, so regressions fail the build.
Configuration
c3x reads from five layers, in increasing priority:
- Built-in defaults
- User config:
~/.config/c3x/config.toml - Project config:
./.c3x.toml - Environment variables prefixed
C3X_ - CLI flags
Project config example (.c3x.toml):
region = "us-east-1"
format = "markdown"
budget = 1000.0 # same as --budget; --budget 0 overrides it
budget_delta = 50.0 # same as --budget-delta on c3x diff
usage_path = "c3x-usage.yml" # relative to this file; used by every command
synced_usage_path = "c3x-usage.synced.yml" # generated usage; see below
[pricing]
endpoint = "https://pricing.c3x.dev/graphql"The other keys are currency, offline, no_cache, cache_path,
no_remote_modules, and pricing.token (keep that one out of the
repository). c3x warns about any key it doesn't recognise.
Usage can come from two files. usage_path is the one you write.
synced_usage_path is a generated snapshot (c3x-usage.synced.yml, picked
up automatically when it sits next to .c3x.toml) with the same schema,
which you never edit by hand. Every command that prices reads both, and
your file wins per resource and per key: a value you write is never
replaced by a generated one, and a generated value fills in what you
leave out. A value measured for one resource beats a defaults: entry
for its whole kind.
Self-hosting the pricing API? Point pricing.endpoint at it and set
C3X_PRICING_TOKEN if it runs with API_KEY enabled. See
c3x-pricing-api.
Architecture
cmd/c3x/ # CLI entry (cobra + viper)
cmd/verify_catalog/ # Catalog health harness
internal/
βββ domain/ # Core types: Resource, Cost, Estimate, Diff
βββ config/ # 5-layer configuration resolution
βββ observability/ # slog + OpenTelemetry tracer
βββ catalog/ # TOML resource definitions + Registry
βββ expr/ # Expression evaluator (expr-lang/expr wrapped)
βββ parser/ # IaC parser dispatcher
β βββ terraform/ # HCL parsing (hashicorp/hcl/v2)
β βββ plan/ # Terraform plan JSON
βββ pricing/ # HTTPSource β DiskCache β MemoCache chain
βββ calculator/ # Orchestrator
βββ render/ # text / markdown / json renderers
resources/ # Embedded TOML catalog snapshot, the
# offline fallback shipped in the binary
testdata/corpus/ # Real-world Terraform used by integration tests
Module dependencies flow inward toward domain. The depguard linter
enforces the boundaries in CI, so an import that crosses the wrong line
fails the lint job before review.
c3x is pre-1.0 and versioned honestly. Flags, environment variables and config keys are not removed without a deprecation cycle, enforced by a CLI-surface snapshot test that fails CI on a silent removal. See COMPATIBILITY.md.
Issues and pull requests are welcome. CONTRIBUTING.md covers the layout, the catalog format, and how to add a resource kind.
Apache License 2.0, see LICENSE.
