Build1 publisher3 min readPublished
88 KB of read-only rows priced Aurora Serverless v2 out of an agentic RAG rewrite
Aurora Serverless v2 idles at about $43 a month at its 0.5-ACU floor, close to three times the whole $15 EC2 box being retired, so the read path became a SQLite file in S3 and the writes went to DynamoDB.
The Engineer · Build desk

What happened
- An agentic RAG assistant built over 284 LLM Zoomcamp capstone submissions moved off a single EC2 box onto Lambda, Bedrock, S3 Vectors, DynamoDB and Step Functions.
- The planned port of its Postgres was Aurora Serverless v2 with the RDS Data API, whose 0.5-ACU minimum costs about $43 a month idle, three times what the whole EC2 box cost.
- A CloudFront Origin Access Control design signing to a Lambda Function URL on AWS_IAM failed for POST, because CloudFront omits the body payload hash and Lambda rejects unsigned payloads.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost The idle floor bills whether anyone visits or not, so at twice-weekly traffic the provisioned capacity costs about $5 a session before Bedrock charges for a single token.
- decision Anyone porting a read-only dataset that a pipeline rebuilds has to justify a managed engine against a file in S3 before writing the migration, and the justification is now a latency argument, not a capability one.
- exposure On a public demo with a shared budget cap, a visitor who closes a tab mid-stream still spends the full function duration and token cost, so the cap can be drained by disconnects.
Aurora Serverless v2 does offer a 0-ACU floor, and its timers are what rule it out here. The minimum auto-pause interval is 300 seconds, a resume takes about 15 seconds, and after a day idle it is 30 or more [6]. A demo visited twice a week leaves gaps of roughly three and a half days, about a thousand times the shortest pause interval available, so every session starts from a paused cluster [2]. The author wrote that at that traffic, "30 seconds before the first byte" is the user experience [8]. Held at 0.5 ACU instead, the same traffic pays about $5 a session in idle capacity [1].
Then the inventory: 278 projects and a few thousand library rows, 88 KB, rebuilt from scratch by the ingestion pipeline and read-only at query time [9]. "That is not a database workload. That is a file," the author wrote [10]. The read path became a projects.sqlite artifact holding the projects and libraries tables, the corpus and an FTS5 index over it, built by the pipeline, published to S3 and pulled into the Lambda's /tmp on cold start [11]. Conversations, feedback and the spend ledger went to DynamoDB [12]. About $43 a month became about $1 [13], or $504 a year [3].
The quality argument runs the same direction as the price. Postgres full-text search has no IDF at all: ts_rank_cd scores on term frequency and position and is blind to the corpus, while SQLite's bm25() does weight rare terms [14]. The app was replacing minsearch indexes [2], so on this corpus the file ranks closer to what it displaced than the managed engine would have [14].
Two queries needed rewriting, not placeholder substitution. SQLite has no DISTINCT ON, and a GROUP BY with MAX() returns the row that produced the maximum, which is the row DISTINCT ON would have kept [17]. ARRAY_AGG(repo ORDER BY attempt) has no portable equivalent, because SQLite only gained ordered aggregates in 3.44 and the Lambda runtime may ship something older, so that grouping moved into Python [18]. The rest carried over because the project's own schema.sql opens with a note that these queries ran on SQLite before the Postgres migration and that the SQL had been kept portable [15]. "Past me left a door open," the author wrote [16].
Three conditions carry the $43-to-$1 swap to another stack: the query-time data is read-only, it is small enough for the pipeline to rebuild and ship as one artifact, and the writes fit a key-value access pattern [9][11][12]. A schema leaning on Postgres-only features costs more than two rewrites. The post gives the database line item and does not total the new stack's monthly bill [3].
Streaming went the same way. Lambda response streaming is supported on Node.js managed runtimes only, and Python needs a custom runtime or the Lambda Web Adapter [19]. The router runs up to six turns of tool calls plus a query rewrite and a rerank, and streaming only helps the final call, so the user waits through the tool chain either way [20]. A client that disconnects mid-stream is still billed the full function duration and the full token spend [21]. The app ships buffered JSON [22].
The last item is a signing detail. The plan was CloudFront with Origin Access Control in front of a Lambda Function URL on AuthType: AWS_IAM, with OAC signing the requests and no shared secrets [23]. CloudFront signs the request but does not compute the body payload hash, and Lambda rejects unsigned payloads, so /api/ask and /api/feedback both fail with InvalidSignatureException [24]. The architecture the post describes puts the Lambda behind a shared-secret header [3].
What to watch
- A shorter minimum auto-pause interval or a faster resume from AWS would reopen the 0-ACU path for low-traffic apps.
- Response streaming on Python managed runtimes would remove the custom-runtime cost of token-by-token output.
- Whether the projects.sqlite artifact still fits the Lambda's /tmp as the corpus grows past 284 submissions.