In June 2026, I started reviewing my AWS bill. The goal was not to shave a few dollars off this month. Even a charge that is only a few dollars today becomes a heavy fixed cost once usage grows 100x. So I pictured that "future amount" first, fixed things while the systems kept running, and shaped the infrastructure to fit. This is the measured record of that work.
The first thing I learned: the thing eating the most money is not the product feature. What was eating money was a NAT Gateway that CDK (a tool that defines AWS infrastructure as code) had quietly stood up (a relay that lets a private network reach the outside; it bills you just for existing), an Amplify (AWS's build and hosting platform) that was only building, a health-check Lambda (an event-driven function runtime) that ran every 15 minutes, and 820,000 rows of junk data piled up in a dev (development) environment nobody was watching. Each is small today, or a charge you never notice. But leave them alone and they grow in proportion to usage and time. So I looked at each through "what happens at 100x" and killed them one by one.
Let me put the conclusion first (about an 11-minute read).
-
The biggest fixed cost was not a product feature but a NAT Gateway that the IaC (infrastructure-as-code) default stood up on its own — if you don't set
natGatewaysexplicitly, two are created, one per AZ (Availability Zone; a data-center partition), costing $58–66/month. "Serverless means zero fixed cost" does not hold automatically. - 87–89% of the Amplify bill was "build time" — break down the actuals and what you should move is not the runtime ($3–7/month) but the build itself. I stopped the git-linked auto-build and switched to promoting a single built artifact.
- The $47/month DynamoDB (AWS's NoSQL database) reads were generated by the monitoring Lambda itself — it re-counted a 1.22GB index in full every 15 minutes. A cost that grows in proportion to data, one you cannot leave alone.
- The visualization itself can be wrong by 100x — the cost dashboard was inflating one provider's charge by 100x. If you judge by numbers, first doubt whether the number is even correct.
My portfolio's bill was almost entirely concentrated in a single news-curation platform; the other products (static sites and small SaaS (software as a service)) were under a few dollars a month. That is exactly why killing the "non-product" costs first paid off. Below is what I measured and how I fixed it, framed through the lens of the future amount.
Front 1: Two NAT Gateways CDK stood up on its own, $64/month
The first large fixed cost was on the uptime-monitoring SaaS side. It was a stack running browser monitoring on ECS Fargate (a container runtime with no server management), and because I had not declared a VPC (virtual private network) to CDK, an implicit VPC was generated and two NAT Gateways stood up, one per AZ. That was $58–66/month. This fixed cost was higher than the Lambda and DynamoDB for the dev-only workload combined — an inversion. A design that claimed "zero-fixed-cost serverless" was being betrayed by a framework default.
I had no IP allowlist (restricting who can connect by source IP) either, so the NAT's fixed egress IP had no value. So I declared the VPC, set NAT explicitly to zero, and switched to direct egress (outbound traffic) from a public subnet (a network segment reachable directly from outside).
// Don't let the implicit VPC stand up a NAT on its own. Declare zero.
const vpc = new ec2.Vpc(this, "MonitorVpc", {
natGateways: 0,
subnetConfiguration: [
{ name: "public", subnetType: ec2.SubnetType.PUBLIC },
],
});
// Fargate tasks go out via a public subnet + public IP
new ecs.FargateService(this, "BrowserWorker", {
cluster,
assignPublicIp: true,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
});
That alone made $58–66/month disappear. The amount may look small. But a NAT is a fixed cost that "bills you just for standing there," and it grows in proportion as you add AZs and products. At $64 today, if the setup grows 100x it becomes that much heavier a fixed cost. So I tipped it explicitly to zero while it was still small. The lesson is simple: serverless does not automatically mean zero fixed cost. Unless you look at what the IaC default stands up, the design principle you raised gets broken behind your back.
Front 2: 87% of the Amplify bill was "build time"
Next is the deploy platform. I was visualizing cost in a home-grown CI/CD (a system that tests and deploys automatically on every change) portal, and when I broke down the Amplify bill with Cost Explorer (AWS's cost-analysis tool), 87–89% of the monthly bill was BuildDuration — time spent building — while the actual runtime (SSR (server-side HTML rendering) hosting) was only $3–7/month. In June it briefly spiked into the $60s.
My original plan was "migrate SSR to another platform," but that touched the 11% side of the cost. The measurement flipped the strategy. What I should touch was the 89% side: stop the build itself. I halted the git-linked auto-build and switched to "build once, promote many" — deploying only the artifact built by my own CI runner. Since one built artifact is promoted dev→staging→prod, builds on Amplify drop to essentially zero. Builds are the side that increases in count as development gets more active, so even if the amount is small today, it grows in proportion in the future. That is why I stopped it wholesale rather than migrating.
Here I hit one platform-specific wall. A git-linked Amplify app rejects the create-deployment API (an API that uploads an artifact directly to deploy). I could not escape it by changing an existing app's settings, so I worked around it by standing up a repo-unlinked "manual-deploy-only app" per environment.
Front 3: The cause of the $47/month was "a health check every 15 minutes"
The third was the 15-minute monitor. DynamoDB read cost on the curation platform had reached $47/month, and following the breakdown, the cause was not a product feature but that a Lambda monitoring the pipeline's health was COUNTing (a full scan that counts every row) a 1.22GB GSI (Global Secondary Index; a secondary index for lookups) across all statuses every 15 minutes. And most of that was re-counting even terminal, excluded statuses (parse errors and anti-bot (bot-blocking) exclusions) that never change once they land. A textbook case of the monitoring itself being the cost source.
The stopgap was to lower the aggregation frequency for the near-static terminal statuses.
# Stop COUNTing all statuses every 15 min;
# count the changeable ones often, thin the terminal ones to every 2 hours
ACTIVE = {"RAW", "CRAWLED", "ANALYZING"} # the moving ones
TERMINAL = {"EXCLUDED_PARSE_ERROR", "EXCLUDED_ANTIBOT", "PUBLISHED"}
def collect_counts(now):
statuses = ACTIVE.copy()
if now.minute < 15 and now.hour % 2 == 0: # terminal ones only once / 2h
statuses |= TERMINAL
return {s: count_by_status(s) for s in statuses}
That cut read cost by about 87% (roughly $47 → $5–6/month). But this is a stopgap. What is scary about this design is that as articles grow, the COUNT target grows too, and cost rises in proportion to data volume. At $47 today, if the number of articles grows 100x the reads grow roughly 100x too. So as the permanent fix I stopped counting the status aggregation on the GSI every time and moved to a counter table that updates counts incrementally via DynamoDB Streams (a mechanism that streams table changes), making the aggregation read O(1) (a constant amount of work regardless of row count). I implemented that the next month. A "periodically count everything" design is worth removing at the root.
Front 4: monitoring, backups, and the dev environment — the "non-product"
Stacking up the fronts, what emerged was that the cost sources gather, without fail, in the "non-product." A backup engine was full-scanning the dev table every day, and the 820,000+ crawl-job traces and article junk piled up there were driving up both the scan volume and the backup volume. I resolved it by purging dev down to 665 operational rows. Even small junk becomes a daily scan cost once 820,000 of them accumulate. On the uptime-monitoring SaaS side too, I dropped 22 dev monitors from a 5-minute to a daily interval ($18→$4/month) and disabled detailed container metrics in prod to cut CloudWatch (AWS's monitoring and metrics platform) cost. Reducing the analysis target by about 94% with pre-ingest triage, holding the per-day analysis cost to $0.13, was part of this same flow.
What worked across the board was a dashboard that gathers the cost of multiple providers onto one screen. Here I also hit a bug that was not funny. One day the Anthropic cost alone displayed as absurdly large, and it turned out that the Admin API (Anthropic's management API) returns cost in cents (the smallest unit) while the script summed it as dollars. The raw value "58.3135" should be $0.58; it had ballooned 100x. A 2-line fix of ÷100 corrected it, and as a lesson I noted "the currency unit differs across all three providers (one is cents, one is dollars, the bill is yen)." To picture the future amount, the premise is that today's number is displayed correctly first. If the visualization is off by 100x, the entire cost judgment goes wrong.
The mechanism: why the "non-product" becomes a future cost
What eventually clicked is that the places where cost tends to appear share a common structure. A product feature is billed only when a request arrives (elastic), whereas NAT, builds, periodic monitoring, and backups are steady costs that "keep running even when nothing is moving and nothing has changed." Steady costs grow quietly in proportion to scale, time, and data volume. And they slip in as unconscious settings — an IaC default, or "let's just do every 15 minutes" — so they never enter the field of view of feature development. That is why you look not at "how much now" but at "how much at 100x." On top of that, I fixed "measure then fix, fix then measure" into the operation, and watch cost changes daily on a dashboard rather than the bill. The bill arrives a month late, so if you only watch that, it is too late. The right infrastructure was not about features working — it was shaping these steady costs so they will not break in the future either.
Takeaways you can reuse
- Look not at today's amount but at "when it's 100x." The smaller the steady cost, the more quietly it grows in proportion to scale and data. Kill it — structure and all — while it's small.
-
Doubt the IaC default. Without setting
natGatewaysexplicitly, a NAT stands up on its own. "Serverless = zero fixed cost" does not hold automatically. - Break the bill into its parts before fixing it. Only once I knew 87% of Amplify was build time did I see that the fix was stopping the 89% side, not migrating the 11% side.
- Periodic execution gets more expensive as it grows. The full COUNT every 15 minutes became O(1) with an incremental counter. Suspect that monitoring and backups can themselves be a cost source.
- Build the visualization correctly. The currency unit differs by provider. If the dashboard is off by 100x, not just the future amount but today's judgment goes wrong.


Top comments (0)