“Free tier” reads like the cost went away. It didn’t. It moved. Every managed cloud service with a free tier is free under a specific set of constraints, and staying inside those constraints costs something even when the invoice says zero. Sometimes that cost is complexity: workarounds and extra plumbing to avoid a service that would otherwise be the obvious default. Sometimes it’s risk: a cliff at the edge of a quota that either throttles you or bills you the moment you cross it, often without warning. Sometimes it’s just time and flexibility: the free option constrains which implementation choices are even on the table.
K2-Chat, a multimodal RAG chat platform, was rebuilt from a Kubernetes cluster billing by the hour into a stack running entirely on AWS, Cloudflare, and Gemini free tiers. Every infrastructure decision in that rebuild is a case study in where the deferred cost actually went.
The relocation shows up service by service
Some services are free forever at low volume. Cloudflare Workers (100,000 requests/day), Cloudflare Pages, Cognito (50,000 MAUs/month), DynamoDB, SQS, and Lambda all fall in that bucket for a project at personal/demo scale. Others are only free for an AWS account’s first 12 months: S3, Glue, Step Functions, CloudWatch Logs. And a third group has no meaningful free tier at all, which is where the interesting decisions happen, because “avoid this service” is itself a design constraint that ripples outward:
| Service | Why it doesn’t fit free-tier constraints | What replaced it | What the replacement costs |
|---|---|---|---|
| NAT Gateway | ~$32+/month just to exist, independent of traffic | No VPC for Lambda | Lambda loses the ability to sit behind a private network boundary |
| API Gateway (REST) | Free tier expires after 12 months; REST runs ~3.5x the cost of HTTP ($3.50/M vs $1.00/M) | Cloudflare Workers | A second platform to operate instead of one AWS-native edge layer |
| RDS / pgvector | No always-free tier, ~$13/month minimum | DynamoDB + Pinecone | Two separate stores instead of one relational one, more moving parts to keep consistent |
| OpenSearch | No workable free tier at this scale | Pinecone | A third-party vendor dependency outside AWS’s billing entirely |
| EMR Serverless | No free tier at all, billed per vCPU/GB-hour with no buffer | AWS Glue (10 DPU-hours/month free) | A much smaller, easier-to-exceed processing budget |
| Bedrock | Pay-per-use, no free tier | Gemini free tier | A model provider chosen by its pricing tier, not by fit for the task |
Every row on the right side is a real engineering cost that a “just use RDS” or “just use Bedrock” version of this stack wouldn’t have paid. None of it shows up as a dollar figure. It shows up as an extra service to operate, an extra API to integrate, or a capability quietly given up.
Where the cost hides even inside the free services
The services that are free don’t stay free automatically. They’re free under specific usage patterns, and the gap between “free tier” and “free regardless of how you use it” is exactly where the risk lives.
CloudWatch log ingestion is billed at $0.50/GB with a 5GB/month free allowance, and log groups don’t expire on their own:
Lambda logging full JSON payloads at DEBUG level, e.g. 5 KB per request times 10 million daily requests, generates 50 GB/day, costing roughly $750/month in log ingestion alone, often more than the Lambda compute itself.
That number has nothing to do with traffic being too high for the free tier in some obvious way. It’s what happens when a default logging level and a default retention setting are left alone. The mitigation isn’t a bigger budget, it’s a configuration choice made in advance: cap every log group’s retention at 7 days and keep log levels at INFO/ERROR rather than DEBUG. That choice is cheap to make before the fact and expensive to discover after.
Glue tells the same story from a different angle. It’s billed at roughly $0.44 per DPU-hour, no free-forever tier, only 10 DPU-hours/month free on a new account:
A 10-worker job running for 1 hour = roughly $4.40.
Ten workers for an hour sounds like a reasonable default for a Spark job. Against this specific budget it’s most of a month’s allowance in one run. The actual mitigation was capping worker count at 2 for a weekly job rather than scaling it up on demand, which is a real constraint on how the ingestion pipeline can be designed, not just a knob turned down after the fact.
NAT Gateway is the cleanest example of the same idea in its purest form: a fixed cost, ~$32+/month, that exists purely for being provisioned, with no free tier to fall into at all. There’s no usage pattern that makes a NAT Gateway free. The only way to avoid the cost is to avoid the architecture that needs it, which is why Lambda in this stack runs without a VPC rather than with one, a decision made specifically to route around a cost with no free path through it.
The engineering-time cost is the least visible one
The most expensive line item in this rebuild wasn’t a dollar amount, it was the time spent making sure the AWS Step Functions state machine actually passed data correctly between steps, because DynamoUpdateItem and GlueStartJobRun tasks replace the entire state input with their own API response unless told otherwise:
# result_path=DISCARD is required: DynamoUpdateItem otherwise replaces
# the entire state input with its own AWS SDK response, wiping
# bucketName/s3Key/documentId/etc. before the next state ever reads them.
mark_processing = tasks.DynamoUpdateItem(
self, "MarkProcessing",
result_path=sfn.JsonPath.DISCARD,
table=table,
...
)
That’s not a free-tier constraint in the dollar sense, but it’s a direct consequence of choosing a serverless orchestration primitive (Step Functions) that’s cheap enough to fit the free-tier budget over a simpler always-on orchestrator that would have cost more per month but less in integration surprises. The same trade shows up in choosing RUN_JOB over the default REQUEST_RESPONSE integration pattern for Glue, since the default pattern reports success the instant the job starts, not when it finishes:
run_ingestion = tasks.GlueStartJobRun(
self, "RunDocumentIngestion",
integration_pattern=sfn.IntegrationPattern.RUN_JOB,
result_path=sfn.JsonPath.DISCARD,
glue_job_name=document_ingestion_job.name,
...
)
Both of these are the kind of detail that a paid, managed orchestration layer with a longer track record might have handled with better defaults. Choosing the free-tier-compatible primitive means accepting that some of that hand-holding isn’t there, and the time spent finding it out belongs on the same ledger as the dollar figures above, even though nobody bills for it directly.
The bottom line
None of this means free tiers are a bad deal. For a project running at personal or demo scale, $0/month for the first 12 months and roughly $0-5/month after is a genuinely good outcome, and it’s achievable without heroics. The point is narrower: reading a free tier as “this part of the system costs nothing” is the wrong mental model. The more accurate one is that this part of the system costs something, and the bill is currently being paid somewhere other than the invoice, whether that’s an hour spent debugging a state machine, a smaller processing budget than the naive design would have used, or a fixed cost sitting one misconfiguration away from arriving all at once. Budgeting for a system honestly means accounting for all three, not just the one AWS shows you.