A usage assumptions file is only as good as the numbers in it. This page shows complete, copyable configurations for the workload shapes CloudBurn prices, explains where each number comes from in your own account, and describes the feedback loop that tells you when a number is missing.
Every field, unit, pool, and limit used here is defined in the usage assumptions schema.
The three parts of a working config
Every file that produces a complete estimate answers three questions:
- What does this resource do per month? That is the assumption field —
lambda.invocations,s3.storage.standardGbMonth,natGateway.processedGb. - Where does the account already sit in the AWS tier? That is
accountContext.usage. Any component whose AWS price has more than one tier stays unknown without it.0is a valid answer. - Is there free tier left, and how much? That is
freeTierplusaccountContext.remainingFreeTier. LeavefreeTierat its"disabled"default on an established account.
Miss part 1 and one component goes unknown. Miss part 2 and a graduated component goes unknown even though you supplied the quantity. That second case is the one people hit first, so the examples below always show the pools.
Where each number comes from
Pull real numbers from the account that already runs the workload, or from the closest environment you have. Cost Explorer grouped by Usage Type with daily granularity gives you the figure AWS actually bills, which is what these fields want.
| Field | Source in your account |
|---|---|
s3.storage.*GbMonth | CloudWatch AWS/S3 → BucketSizeBytes, filtered by the matching StorageType (StandardStorage, DeepArchiveStorage, IntelligentTieringFAStorage, …) |
s3.requests.tier1 / tier2 | Cost Explorer usage types Requests-Tier1 and Requests-Tier2, or S3 request metrics if you have them on |
s3.dataTransfer.outToInternetGb | Cost Explorer, DataTransfer-Out-Bytes for the bucket's Region |
sqs.requests, sns.* | Cost Explorer usage type for the queue or topic — already normalized to 64 KB request units |
logs.ingestedGb | CloudWatch AWS/Logs → IncomingBytes (Sum) for the log group |
logs.storedGb | aws logs describe-log-groups → storedBytes |
efs.storage.*GbMonth | CloudWatch AWS/EFS → StorageBytes with the StorageClass dimension (Standard, IA, Archive) |
lambda.invocations | CloudWatch AWS/Lambda → Invocations (Sum) per function |
lambda.averageDurationMs | CloudWatch AWS/Lambda → Duration (Average) per function |
apigateway.requests | CloudWatch AWS/ApiGateway → Count (Sum) per API |
kinesis.onDemand.ingestedGb | CloudWatch AWS/Kinesis → IncomingBytes (Sum) |
kinesis.onDemand.retrievedGb | CloudWatch AWS/Kinesis → GetRecords.Bytes (Sum) |
natGateway.processedGb | CloudWatch AWS/NATGateway → BytesInFromSource + BytesInFromDestination (Sum) |
resolver.queries | Route 53 Resolver query logs, or Cost Explorer usage type for the endpoint |
accountContext.usage.* | Cost Explorer for the whole account, grouped by Usage Type, for the same month |
accountContext.remainingFreeTier.* | Billing console → Free tier page, "Usage remaining this month" |
Two fields need normalizing before they go in the file, and both trip people up: SQS and SNS take billable request units rather than message counts, and lambda.averageDurationMs is a per-invocation average rather than a monthly total. The SQS and Lambda sections of the schema reference give the exact conversions.
A minimal starter
The smallest useful file: one function, one pool per graduated component, no free tier.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"accountContext": {
"usage": {
"lambda.requests": 0,
"lambda.duration.x86": 0
}
},
"resources": {
"ApiStack/OrdersHandler": {
"lambda": { "invocations": 4000000, "averageDurationMs": 180 }
}
}
}
The function's declared MemorySize and Architectures come from the diff, so those two assumptions are all the report needs to derive compute GB-seconds — and it shows the arithmetic in the component explanation. Change the memory in a later pull request and the report attributes the delta to the infrastructure input rather than to your assumptions.
Serverless API
A realistic serverless stack: an HTTP API in front of two functions, a queue, and a fan-out topic. Note the per-architecture Lambda pools — an arm64 function advances lambda.duration.arm, never the x86 pool.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"accountContext": {
"usage": {
"lambda.requests": 120000000,
"lambda.duration.x86": 900000,
"lambda.duration.arm": 2400000,
"apigateway.requests.http": 45000000,
"sqs.requests": 60000000,
"sns.requests.standard": 8000000
}
},
"resourceTypeDefaults": {
"AWS::Lambda::Function": {
"lambda": { "invocations": 500000, "averageDurationMs": 120 }
}
},
"resources": {
"ApiStack/PublicApi": {
"apigateway": { "requests": { "value": 30, "unit": "million-requests" } }
},
"ApiStack/OrdersHandler": {
"lambda": { "invocations": 30000000, "averageDurationMs": 210 }
},
"ApiStack/ReceiptRenderer": {
"lambda": { "invocations": 2000000, "averageDurationMs": 1400 }
},
"ApiStack/OrdersQueue": {
"sqs": { "requests": { "value": 90, "unit": "million-requests" } }
},
"ApiStack/OrderEventsTopic": {
"sns": {
"publishedMessages": { "value": 30, "unit": "million-requests" },
"deliveries": { "sqs": { "value": 30, "unit": "million-requests" } }
}
}
}
}
Two details worth copying. The resourceTypeDefaults entry keeps every other function in the diff from reporting as unknown — a new helper function added in a later pull request gets a defensible placeholder instead of a hole in the estimate. And sns.deliveries.sqs is stated on the topic alongside the publish count: SNS delivery to SQS is a separate billable dimension, and omitting it would leave that component unknown rather than free. It needs no accountContext entry, because AWS prices it at a single rate with no tiers.
For a WebSocket API, swap the request field for the two WebSocket fields and their pools — they bill separately, so state a real zero rather than omitting one:
{
"accountContext": {
"usage": {
"apigateway.websocket.messages": 0,
"apigateway.websocket.connectionMinutes": 0
}
},
"resources": {
"RealtimeStack/EventsSocket": {
"apigateway": {
"websocket": {
"messages": { "value": 12, "unit": "million-messages" },
"connectionMinutes": { "value": 4, "unit": "million-minutes" }
}
}
}
}
}
Storage with lifecycle transitions
An archival data lake exercises the part of S3 pricing that surprises people: the transition requests and the retrieval charges, not the storage rate.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"accountContext": {
"usage": {
"s3.storage.standard": 51200,
"s3.storage.deepArchive": 180000,
"s3.requests.standardTier1": 12000000,
"s3.requests.standardTier2": 90000000,
"s3.lifecycleTransitions.deepArchive": 400000,
"s3.retrieval.deepArchive": 250,
"awsDataTransfer.outboundInternet": 8000
}
},
"repositoryDefaults": {
"s3": { "requests": { "tier2": { "value": 5, "unit": "million-requests" } } }
},
"resourceTypeDefaults": {
"AWS::S3::Bucket": {
"s3": { "storage": { "standardGbMonth": 100 } }
}
},
"resources": {
"StorageStack/RawEventsBucket": {
"s3": {
"storage": {
"standardGbMonth": { "value": 2, "unit": "TB" },
"deepArchiveGbMonth": 10000
},
"requests": { "tier1": { "value": 200, "unit": "thousand-requests" } },
"lifecycleTransitions": { "deepArchive": { "value": 50, "unit": "thousand-requests" } },
"retrieval": { "deepArchiveGb": 50 },
"dataTransfer": { "outToInternetGb": 1000 }
}
},
"module.analytics.aws_s3_bucket.curated": {
"s3": {
"storage": { "standardIaGbMonth": 4000 },
"requests": { "tier2": { "value": 20, "unit": "million-requests" } }
}
}
}
}
RawEventsBucket overrides the resource-type storage default and adds archival storage, destination-specific transitions, retrieval, and outbound transfer. Every other bucket in the diff inherits 100 GB of Standard storage and 5 million Tier 2 requests from the broader layers. The Terraform address in the same file is deliberate: one repository can hold CDK and Terraform addresses side by side.
The transitions field names its destination. s3.requests.lifecycleTransition without a destination reports as unknown, because AWS charges a different rate per destination class — and so does s3.dataTransfer.gb without a direction.
Observability and shared storage
CloudWatch Logs and EFS both split into classes that bill under separate dimensions, and both refuse to infer one class from another.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"accountContext": {
"usage": {
"logs.ingestion.standard": 2400,
"logs.ingestion.infrequentAccess": 600,
"logs.storage": 18000,
"efs.storage.standard": 3000,
"efs.storage.infrequentAccess": 12000,
"efs.storage.archive": 40000,
"efs.access.infrequentAccess": 500,
"efs.access.archive": 100
}
},
"resourceTypeDefaults": {
"AWS::Logs::LogGroup": {
"logs": { "ingestedGb": 5, "storedGb": 20 }
}
},
"resources": {
"PlatformStack/ApiAccessLogs": {
"logs": { "ingestedGb": 320, "storedGb": 1900 }
},
"PlatformStack/AuditTrailLogs": {
"logs": { "ingestedGb": 900, "storedGb": 10800 }
},
"PlatformStack/SharedAssets": {
"efs": {
"storage": {
"standardGbMonth": 250,
"infrequentAccessGbMonth": 4000,
"archiveGbMonth": 20000
},
"access": {
"infrequentAccessReadGb": 120,
"infrequentAccessWriteGb": 0,
"archiveReadGb": 5,
"archiveWriteGb": 900
}
}
}
}
}
AuditTrailLogs is declared with LogGroupClass: INFREQUENT_ACCESS in the stack, so its ingestion prices against the Infrequent Access dimension and claims nothing from the Standard free tier. Storage bills under one dimension for both classes.
The EFS access fields show the rule that catches people: reads and writes bill separately per class, so "infrequentAccessWriteGb": 0 is a statement, not a placeholder. Omitting it would leave that component unknown. A file system that transitions data into Archive but supplies no archiveGbMonth reports Archive storage as unknown — CloudBurn never invents the quantity from the lifecycle policy.
Streaming and network egress
Network is where "just give me a number" fails hardest, because every dimension needs an explicit direction or geography.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"accountContext": {
"usage": {
"awsDataTransfer.outboundInternet": 22000,
"resolver.queries": 900000000
}
},
"resources": {
"IngestStack/EventStream": {
"kinesis": {
"onDemand": { "ingestedGb": 3200, "retrievedGb": 9600 },
"retention": { "longTermStoredGbMonth": 1800 }
}
},
"IngestStack/AnalyticsConsumer": {
"kinesis": { "enhancedFanOut": { "retrievedGb": 3200 } }
},
"NetworkStack/EgressNat": {
"natGateway": { "processedGb": 14000 }
},
"NetworkStack/EdgeAccelerator": {
"globalAccelerator": {
"dataTransfer": {
"inboundFromEuropeGb": 800,
"outboundToEuropeGb": 6400,
"inboundFromNorthAmericaGb": 300,
"outboundToNorthAmericaGb": 2100
}
}
},
"NetworkStack/CorporateVpn": {
"vpn": { "dataTransfer": { "outToInternetGb": 450 } }
}
}
}
Enhanced fan-out retrieval lives on the AWS::Kinesis::StreamConsumer, not on the stream, so each registered consumer carries its own quantity. Global Accelerator takes independent inbound and outbound quantities per client geography; an undirected globalAccelerator.dataTransfer.gb reports as unknown rather than being priced at the cheaper direction. VPN outbound transfer shares the awsDataTransfer.outboundInternet pool with S3 egress, which is why one account pool covers both.
The NAT gateway is the simplest entry here and often the most expensive: natGateway.processedGb is a single undirected number, because AWS bills every byte the gateway processes at one rate in either direction. It needs no account pool for the same reason.
Layering defaults across an organization
Precedence is what keeps a large repository's file small. Set the broad numbers once and override only the resources that differ.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"accountContext": {
"usage": {
"lambda.requests": 120000000,
"lambda.duration.x86": 900000
}
},
"organizationDefaults": {
"lambda": { "invocations": 250000, "averageDurationMs": 90 }
},
"repositoryDefaults": {
"lambda": { "averageDurationMs": 140 }
},
"resourceTypeDefaults": {
"AWS::Lambda::Function": {
"lambda": { "invocations": 400000 }
}
},
"resources": {
"BatchStack/NightlyReport": {
"lambda": { "invocations": 30, "averageDurationMs": 240000 }
}
}
}
Reading it back, narrowest layer wins per field: every function would start at 250,000 invocations from the organization layer, but the resource-type layer raises that to 400,000; the repository layer sets the average duration to 140 ms for all of them; and NightlyReport overrides both fields because it runs 30 times a month for four minutes. The report names the scope that supplied each quantity, so a reviewer can see at a glance which numbers were inherited and which were deliberate.
Keys under resources must identify exactly one changed resource. Use the full CDK construct path (BatchStack/NightlyReport) or the full Terraform address (module.batch.aws_lambda_function.nightly_report). Wildcards are rejected, and a bare construct name that matches two changed resources fails the whole block.
A new account still on the free tier
Free tier is opt-in because CloudBurn cannot see your billing console. Enable it, then state what is actually left this month.
{
"schemaVersion": 2,
"revision": "2026-08-01",
"freeTier": "enabled",
"accountContext": {
"usage": {
"lambda.requests": 0,
"lambda.duration.x86": 0,
"lambda.duration.arm": 0,
"apigateway.requests.http": 0,
"s3.storage.standard": 0,
"s3.requests.standardTier1": 0,
"s3.requests.standardTier2": 0
},
"remainingFreeTier": {
"lambda.requests": 850000,
"lambda.duration": 310000,
"apigateway.requests.http": 1000000,
"s3.storage.standard": 5,
"s3.requests.standardTier1": 2000,
"s3.requests.standardTier2": 20000
}
},
"resources": {
"ApiStack/OrdersHandler": {
"lambda": { "invocations": 200000, "averageDurationMs": 150 }
},
"ApiStack/PublicApi": {
"apigateway": { "requests": 200000 }
},
"StorageStack/UploadsBucket": {
"s3": { "storage": { "standardGbMonth": 3 }, "requests": { "tier1": 1500, "tier2": 9000 } }
}
}
}
Note the two lambda.duration.* entries under usage against the single lambda.duration entry under remainingFreeTier. That is not a typo: the billing pools split per architecture while the free allowance does not, and the free tier reference lists which pool name each side takes. If you enable free tier but leave out a pool an eligible component needs, that component reports as unknown rather than silently pricing at full rate.
You can also enable free tier for one scope only:
{
"schemaVersion": 2,
"freeTier": "disabled",
"resources": {
"SandboxStack/ScratchBucket": {
"freeTier": "enabled",
"s3": { "storage": { "standardGbMonth": 2 } }
}
}
}
Prove the config, then keep it honest
The cost report is the feedback loop. Open a pull request and read it before trusting the file.
- An unknown component names the exact input it is missing — an assumption field,
accountContext.usage.<pool>, oraccountContext.remainingFreeTier.<pool>. Add what it names and the component becomes assumed on the next push. - An assumed component names the value, unit, scope, and field that supplied it. If the scope surprises you, a broader layer is winning that you forgot about.
- A report with any unknown component labels its subtotal as excluding unknowns and never uses no-cost wording, so an incomplete file is visible rather than quietly optimistic.
To keep the numbers from rotting:
- Bump
revisionon every edit. It is a free label, and it makes "when did this estimate change and why" answerable from the diff alone. - Re-measure quarterly, or after any launch that changes traffic shape. Assumptions age faster than infrastructure does.
- Write explicit zeros.
0means "this workload genuinely does none of that." Omitting the field means "nobody has looked yet," and the report renders the two differently. - Review assumption edits like code. Because both snapshots are priced, a pull request that only changes assumptions shows its own cost delta — which is exactly when a wrong number is cheapest to catch.
What's next
| Read this | To learn |
|---|---|
| Usage assumptions schema | Every field, unit, pool, and limit in full |
| GitHub App overview | How the app turns diff comments into cost reports |
| Set up PR cost estimation | Installing and linking the app from your dashboard |