# The AWS bill of a three-person startup, line by line

Everyone publishes architecture diagrams. Nobody publishes the invoice. That's a shame, because the invoice is where the architecture becomes honest: the service you were proud of turns out to be a rounding error, and the thing you never thought about is the third-largest line.

We can't publish our client's bill either. What we can do is the next best thing. Below is the architecture we run for a US client platform, priced line by line at list price in us-east-1 for a typical month, with no commitments and no credits. The real invoice has the same ranking and the same surprises. Treat the numbers as the shape, not the receipt.

## What's running

Three engineers, three AWS accounts (dev, staging, prod), all deployed from one CDK codebase. In each account: three Next.js applications on App Runner, an Aurora Serverless v2 PostgreSQL cluster, a handful of DynamoDB tables, about a dozen Lambda functions (some of them inside the VPC so they can reach Aurora), S3 buckets behind CloudFront, a WAF in front of the public app, CodeBuild for images, Secrets Manager and KMS for everything sensitive, CloudWatch for logs and alarms. Production runs two warm App Runner instances for the public app; dev and staging run everything at the smallest size.

## The bill

| Line | Monthly (list) | Share | What drives it |
|---|---|---|---|
| App Runner (9 services across 3 accounts) | ~$190 | 25 % | 2 warm 2 vCPU / 4 GB instances in prod; the rest idles at minimum size |
| Aurora Serverless v2 (3 clusters) | ~$130 | 17 % | 0.5 ACU floor in prod around the clock; dev and staging auto-pause |
| NAT Gateway (3, one per account) | ~$100 | 13 % | $0.045 per hour each, before a single byte is processed |
| CloudWatch (logs, metrics, alarms) | ~$95 | 13 % | ~5 GB of logs a day at $0.50 per GB ingested |
| CloudFront + S3 | ~$55 | 7 % | ~500 GB out, 200 GB stored |
| DynamoDB (on-demand, PITR on) | ~$35 | 5 % | ~10 M writes, 50 M reads |
| Secrets Manager (3 accounts) | ~$33 | 4 % | $0.40 per secret per month, about 25 secrets per account |
| KMS (customer-managed keys) | ~$33 | 4 % | $1 per key per month, about 10 keys per account |
| Data transfer, inter-AZ | ~$20 | 3 % | Lambda ↔ Aurora across zones, plus the odd egress |
| Lambda | ~$15 | 2 % | ~20 M invocations |
| CodeBuild | ~$15 | 2 % | ~200 builds a month, 8 minutes each |
| WAF | ~$16 | 2 % | Web ACL + 5 managed rule groups + 10 M requests |
| Route 53, ECR, SES, SQS, SNS | ~$15 | 2 % | Long tail |
| **Total** | **~$750** | | |

Somewhere between $700 and $850 in a normal month, for a platform with real users, real money flowing through it, and three isolated environments. That's less than one engineer-day at consulting rates. Keep that in mind when someone proposes a week of cost optimisation.

<div class="article-figure">
<svg viewBox="0 0 900 330" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Horizontal bar chart of the monthly AWS bill by service: App Runner 25 percent, Aurora Serverless v2 17 percent, NAT Gateway 13 percent, CloudWatch 13 percent, CloudFront and S3 7 percent, DynamoDB 5 percent, Secrets Manager 4 percent, KMS 4 percent, everything else 12 percent. The three lines nobody budgeted for, NAT, CloudWatch and Secrets plus KMS, are highlighted.">
<g font-family="Inter,system-ui,sans-serif" font-size="13">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Share of the monthly bill</text>
<g fill="#9aa3c7">
<text x="20" y="60">App Runner</text><rect x="200" y="48" width="500" height="16" rx="3" fill="#7b8cff"/><text x="710" y="60" fill="#f1f3ff">25 %</text>
<text x="20" y="90">Aurora Serverless v2</text><rect x="200" y="78" width="340" height="16" rx="3" fill="#7b8cff"/><text x="550" y="90" fill="#f1f3ff">17 %</text>
<text x="20" y="120">NAT Gateway</text><rect x="200" y="108" width="260" height="16" rx="3" fill="#ff6b8a"/><text x="470" y="120" fill="#ff6b8a">13 % · nobody budgeted this</text>
<text x="20" y="150">CloudWatch</text><rect x="200" y="138" width="260" height="16" rx="3" fill="#ff6b8a"/><text x="470" y="150" fill="#ff6b8a">13 % · grows with traffic</text>
<text x="20" y="180">CloudFront + S3</text><rect x="200" y="168" width="140" height="16" rx="3" fill="#7b8cff"/><text x="350" y="180" fill="#f1f3ff">7 %</text>
<text x="20" y="210">DynamoDB</text><rect x="200" y="198" width="100" height="16" rx="3" fill="#7b8cff"/><text x="310" y="210" fill="#f1f3ff">5 %</text>
<text x="20" y="240">Secrets Manager + KMS</text><rect x="200" y="228" width="160" height="16" rx="3" fill="#ffd166"/><text x="370" y="240" fill="#ffd166">8 % · the price of three accounts</text>
<text x="20" y="270">Everything else</text><rect x="200" y="258" width="240" height="16" rx="3" fill="#2a3150"/><text x="450" y="270" fill="#f1f3ff">12 %</text>
</g>
<text x="20" y="312" fill="#9aa3c7" font-size="12">Compute you chose on purpose: 42 %. Plumbing you got by default: 58 %.</text>
</g>
</svg>
</div>

## The three surprises

**NAT Gateway is a fixed tax on having a VPC.** We have Lambdas that talk to Aurora, so they live in private subnets, so they need a NAT to reach anything on the internet, including AWS APIs. One NAT per account is about $33 a month before it processes a byte. Three accounts, three NATs, $100. That's more than Lambda, DynamoDB and WAF combined. The fix is partial: gateway endpoints for S3 and DynamoDB are free and remove most of the traffic, interface endpoints for Secrets Manager and the like cost $7 each so they only pay off above a certain volume, and in dev we're seriously considering having no NAT at all and letting the Lambdas that need the internet fail loudly.

**Security has a per-account price.** Secrets Manager charges per secret, KMS charges per key, and CloudTrail wants its own bucket. None of it is expensive on its own. Multiply by three accounts and it's 8 % of the bill, roughly what CloudFront costs to serve every byte of the product. We reduced it by grouping related values into one JSON secret (one secret, several keys) instead of one secret per value. We didn't reduce the KMS keys: one key per data store per account is the whole point of [the three-account setup](/en/blog/aws-three-accounts-one-cdk-codebase).

**Logs cost more than the functions that write them.** CloudWatch Logs at $0.50 per GB ingested is the most expensive line that scales with traffic rather than with headcount. Five gigabytes a day is nothing for three applications that log every request in JSON, and it's already $75 a month. We cut retention to 30 days, moved noisy log groups to the Infrequent Access class at half the ingestion price, and stopped logging request bodies. The longer-term answer is [not letting CloudWatch own application logs at all](/en/blog/your-logs-should-not-know-which-cloud).

## The thing that isn't a surprise but should worry you

Non-production is about a third of the bill. Dev and staging are "small", and they still carry a NAT each, an Aurora cluster each, nine App Runner services between them, and their own copies of every secret and key. Auto-pause on Aurora Serverless v2 (0 ACU when idle, since late 2024) took the two non-prod clusters from about $90 to about $20. App Runner at minimum size is $5 a service, which is fine. The NATs are the remaining problem.

<div class="article-figure">
<svg viewBox="0 0 900 220" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stacked bar showing production at about 65 percent of the bill and dev plus staging at about 35 percent, with the non-production share broken down into NAT gateways, App Runner at minimum size, paused Aurora clusters and secrets and keys.">
<g font-family="Inter,system-ui,sans-serif" font-size="13">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Where the money goes by environment</text>
<rect x="20" y="50" width="553" height="34" rx="4" fill="#7b8cff"/>
<text x="296" y="72" text-anchor="middle" fill="#0d1120" font-weight="700">prod · ~65 %</text>
<rect x="577" y="50" width="298" height="34" rx="4" fill="#ffd166"/>
<text x="726" y="72" text-anchor="middle" fill="#0d1120" font-weight="700">dev + staging · ~35 %</text>
<line x1="577" y1="84" x2="577" y2="110" stroke="#ffd166" stroke-dasharray="4,3"/>
<line x1="875" y1="84" x2="875" y2="110" stroke="#ffd166" stroke-dasharray="4,3"/>
<rect x="577" y="112" width="80" height="26" fill="#ff6b8a"/><text x="617" y="130" text-anchor="middle" fill="#0d1120" font-size="11">2 NATs</text>
<rect x="657" y="112" width="70" height="26" fill="#7b8cff"/><text x="692" y="130" text-anchor="middle" fill="#0d1120" font-size="11">App Runner</text>
<rect x="727" y="112" width="60" height="26" fill="#4fffb0"/><text x="757" y="130" text-anchor="middle" fill="#0d1120" font-size="11">Aurora</text>
<rect x="787" y="112" width="88" height="26" fill="#9aa3c7"/><text x="831" y="130" text-anchor="middle" fill="#0d1120" font-size="11">secrets, keys, logs</text>
<text x="577" y="170" fill="#9aa3c7" font-size="12">Aurora auto-pause already cut the non-prod share by ~10 points.</text>
<text x="577" y="190" fill="#9aa3c7" font-size="12">The two NATs are the next target.</text>
</g>
</svg>
</div>

## What we deliberately don't optimise

We pay on-demand for everything. No Savings Plans, no reserved capacity, no Aurora reserved instances. At $750 a month the maximum saving from a one-year commitment is about $100 a month, and the platform's shape is still changing: App Runner may become Fargate, Aurora may become plain RDS, one of the Lambdas may become a container. A commitment is a bet that the architecture won't move, and we're not ready to make that bet. We'll revisit it when the bill crosses roughly $3,000, which is where the conversation is worth an afternoon.

We also don't chase the long tail. Route 53, ECR, SES and SQS add up to $15. Any hour spent there is an hour lost.

## How to read your own bill

Three habits that made this exercise possible and that we'd recommend to any team of our size:

- **Turn on Cost Explorer with hourly granularity and tag by environment from day one.** Ours is a single CDK tag, `env`, applied at the stack level. Without it "prod vs non-prod" is a guess.
- **Look at the bill by usage type, not by service.** "EC2-Other" is where NAT gateways hide. "CloudWatch" mixes ingestion, storage and API calls that have completely different fixes.
- **Set a budget alert at 120 % of last month.** Not to save money, but to find out about a change on day three instead of day thirty. Ours has fired twice: once for a runaway log group, once for a Lambda retrying a failed webhook every minute for a week.

The bill isn't a problem at this size. It's a map. It told us that the interesting engineering isn't in the compute we chose, but in the plumbing that came with it, and that's where the next three articles go: [NAT gateways](/en/blog/nat-gateway-the-most-expensive-line-you-do-not-see), [logs](/en/blog/your-logs-should-not-know-which-cloud), and [commitments](/en/blog/reserved-savings-plans-or-nothing-at-small-scale).

If you'd like a second pair of eyes on your own invoice, [talk to us](/contact). We read them for a living.
