blog

From Side Project to Production: a stack that idles at $0.40

In June I gave a talk at commIT Berlin called From Side Project to Production: Cost-Efficient Serverless Development. It was a slide deck and twenty-odd minutes, which is enough room for the argument and not much else. This is the long version with some updates that happened since.

Every side project I’ve started has hit the same four constraints at once:

  • You have an idea on a Sunday, and it’s worth building for real rather than as a toy.
  • You want a real database. Not SQLite on a disk that disappears with the container, not a JSON file.
  • You don’t want a $30 bill for three users. Infrastructure to experiment with shouldn’t cost like infrastructure to run a business on.
  • If it lands on Hacker News, it can’t fall over. It has to survive the one day it gets popular.

The first two push you toward a proper hosted database. The third pushes you away from one, because a proper hosted database is usually the most expensive thing on a small bill. The fourth rules out the cheap answer of one small VM. That tension is the whole talk.

Scale to zero is a property of the whole stack

The pitch for serverless is that you pay for what you use. That’s true per component and misleading per stack. Your idle bill is the sum of every piece with an hourly or monthly floor, and everything genuinely pay-per-use rounds to nothing next to it. So the question isn’t “is this service serverless”, it’s “which line on my bill does not scale to zero”.

My receipts for this are MCA Benches, a Next.js app I run for a few Guild Wars 2 guilds on exactly the stack in this post. Its whole idle bill is roughly $0.40 a month, and all of it is one Secrets Manager secret. Lambda, CloudFront, S3, logs and the database are pure pay-per-use, and a low-traffic guild mostly stays inside the free tiers.

What usually sits there instead is a NAT Gateway, north of $30 a month before it forwards a byte. It turns up because the database lives in a VPC, so the Lambda has to join the VPC to reach it, and a Lambda in a VPC needs a NAT Gateway to reach anything else on the internet. On a small stack it’s usually the largest line on the bill, and it has nothing to do with your app.

The floors on AWS are almost always one of two things: network plumbing (NAT Gateways, load balancers, VPC endpoints) or a capacity reservation (provisioned instances, minimum capacity units, shards). Once you see that, you can usually predict which service has a floor before you look up its pricing page. However, there are a few where the name is decieving like OpenSearch Serverless.

Of course, sometimes the floor is correct. A VPC is a real security posture and not a mistake. However, if everything is using IAM-auth only, the first for a side-project is usually acceptable.

The database that removes the floor

The database was what the serverless stack historically fell apart and also the reason I did this talk.

Traditional DBServerless NoSQLAurora DSQL
Idle cost$15-30 a month minimumscales to zeroscales to zero
QueryingSQL, joinsdesign around access patternsSQL, joins
Changing queriesfinemay mean remodelling the tablefine
Connectionspool limitsnone, it’s HTTPno pool to size
Credentialspasswords to rotateIAMIAM, no password at all

DynamoDB gets you to zero, and I like it, but it gets you there by making you commit to your access patterns up front. On a side project you don’t know your access patterns yet, because you don’t know what the product is yet. That’s what it means to be a side project.

Aurora DSQL is the option that didn’t exist a couple of years ago: serverless, distributed, PostgreSQL-compatible, with no instances to size and nothing to provision. The pricing is the reason it fits here. You pay $8 per million DPUs (its unit of work) and $0.33 per GB-month of storage, and the first 100,000 DPUs and 1 GB are free every month. A personal site, a budgeting app or a small CRM with a few hundred transactions a day sits inside it.

The property that matters most is one the pricing page doesn’t mention. DSQL authenticates with IAM over a public endpoint. The Lambda signs a short-lived token with its own execution role and connects out like any other AWS API call. No VPC, so no NAT Gateway and no VPC endpoints standing in for one. That single property does more for the idle bill than anything else in the stack.

Schema, and what changed since the talk

With Drizzle, the schema looks like any other Postgres schema:

import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'

export const posts = pgTable('posts', {
  id: uuid('id').defaultRandom().primaryKey(),
  title: text('title').notNull(),
  content: text('content'),
  createdAt: timestamp('created_at').defaultNow(),
})

export const comments = pgTable('comments', {
  id: uuid('id').defaultRandom().primaryKey(),
  postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
  body: text('body').notNull(),
})

Two notes:

  • UUIDs as primary keys. A serial key means every insert coordinates on one counter, and in a distributed database that’s a hot spot. DSQL does support sequences and identity columns, but AWS’s own guidance is still random IDs for an even spread, and a side project loses nothing by following it.
  • That references() is new. In June my slide said DSQL has no foreign keys, so relationships went through Drizzle’s relations() and integrity lived in application code. Since the end of August it enforces them, with CASCADE, SET NULL and the rest. They work differently from single-node Postgres, though. DSQL checks the referenced row against your transaction’s snapshot rather than locking it, then resolves conflicts at commit. So if someone deletes a post while you’re inserting a comment on it, your insert doesn’t wait, it fails with a serialization error, and your code needs to retry. Every write to either table also does extra reads to check the constraint, which you pay for in DPUs.

Connecting, and the bug on my slide

This is the code I showed in the talk:

import { DsqlSigner } from '@aws-sdk/dsql-signer'
import { drizzle } from 'drizzle-orm/node-postgres'
import pg from 'pg'

const signer = new DsqlSigner({ hostname: DSQL_ENDPOINT })
const token = await signer.getDbConnectAdminAuthToken()

const pool = new pg.Pool({
  host: DSQL_ENDPOINT, user: 'admin',
  password: token, database: 'postgres', ssl: true,
})

export const db = drizzle(pool)

The slide next to it said the token is short-lived and refreshes itself. The first half is true. The second half isn’t true of this code.

The token is signed once, when the module loads, and by default it expires after 15 minutes. An open connection survives its token expiring, because the token is only checked at connect time. But the pool opens new connections whenever it needs them, and DSQL also closes every connection after 60 minutes regardless. A Lambda instance can easily live longer than 15 minutes on a steady trickle of traffic. So the first time that instance needs a fresh connection, it offers a dead token as its password and the query fails. It works in every demo and in every test, because nothing in a demo lives that long.

The fix is small. pg accepts a function as the password and calls it for each new connection, so every connection gets a fresh token:

const pool = new pg.Pool({
  host: DSQL_ENDPOINT, user: 'admin', database: 'postgres', ssl: true,
  password: () => signer.getDbConnectAdminAuthToken(),
})

Signing is a local operation with no network call, so doing it per connection costs nothing. AWS now also publishes DSQL connectors for Node.js that handle this for you, and that’s what I’d reach for in a new project.

Deploying it

Every one of these bills to zero at idle, so the choice is about how you like to work rather than about cost:

OptionWhat it isFits
AWS Amplifygit-push deploys, near zero configthe fastest start
Vercelgreat DXyou’re already there
SST (OpenNext)high-level infrastructure as codeAWS-native teams
CDK (OpenNext)full infrastructure-as-code controlCDK shops

One caveat on Vercel: the “no password anywhere” story depends on your code running with an AWS role. On Lambda that’s the execution role. On Vercel you get there with OIDC federation into an AWS role, which works but is one more thing to set up.

For a side project I’d pick SST, because one file describes the whole stack:

export default $config({
  app: () => ({ name: 'my-app', home: 'aws' }),
  async run() {
    const cluster = new sst.aws.Dsql('MyCluster')

    new sst.aws.Nextjs('Web', {
      link: [cluster], // typed access, and the IAM permission to connect
    })
  },
})

link is what makes SST fun, it grants the Next.js functions permission to connect to the cluster and hands your code a typed reference to it, so there’s no endpoint to copy into an .env file and keep in sync:

import { Resource } from 'sst'

const signer = new DsqlSigner({ hostname: Resource.MyCluster.endpoint })

const pool = new pg.Pool({
  host: Resource.MyCluster.endpoint, user: 'admin', database: 'postgres', ssl: true,
  password: () => signer.getDbConnectAdminAuthToken(),
})

What OpenNext actually deploys

The Vercel-shaped part of Next.js doesn’t map onto one AWS service. OpenNext splits it into several and puts CloudFront in front to route between them:

flowchart LR
  Browser --> CF[CloudFront]
  CF -- "/_next/static" --> S3[(S3 assets)]
  CF -- "/_next/image" --> Img[Image function]
  CF -- "everything else" --> Server[Server function]
  Server --> DSQL[(Aurora DSQL)]
  Server --> Cache[(ISR cache<br/>S3 + DynamoDB)]
  Server --> Q[Revalidation queue<br/>SQS]
  Q --> Reval[Revalidation function]

Everything in that diagram is pay-per-use. The one piece to be deliberate about is the optional warmer, a scheduled function that pings the server function so it doesn’t go cold. It’s cheap, but by definition it means your stack is never idle, so on a side project I’d leave it off and accept the occasional cold start.

Monday

The slide for this was the one people remembered: Sunday, $0 with three users. Monday, the Hacker News front page. Tuesday, back to $0. No reprovisioning, no config change, no connection string to rotate, and when the traffic goes away the bill goes with it.

That holds, with one number worth knowing before the Monday rather than during it. DSQL accepts 100 new connections per second per cluster, with a burst of 1,000. A traffic spike on Lambda is a burst of cold starts, and each cold start opens its own connections. For a side project that’s a lot of headroom, but it’s a ceiling, and it’s the kind you find out about from an error rate. Keep the pool small per Lambda instance (one or two connections is plenty, since an instance handles one request at a time) and it stays theoretical.

When DSQL is the wrong choice

  • You need triggers, PL/pgSQL or extensions such as PostGIS. DSQL supports plain SQL functions, but trigger-style logic moves into your application or onto EventBridge. If you want Postgres with everything, Aurora Serverless v2 is the answer, with the VPC and the floor that come with it.
  • The workload is analytical. Athena or Redshift, not a transactional database.
  • You already have a tuned, provisioned database that fits. Don’t migrate for the sake of it.
  • You write in big batches. A transaction can modify at most 3,000 rows and 10 MiB, and can run for at most five minutes. That never matters for the app itself, but it matters the first time you run a backfill or an import, which has to be chunked.

The takeaway

A fully serverless stack costs nothing while it’s a side project and keeps up when it stops being one, as long as every layer scales to zero rather than just the ones in the brochure. Frontend, compute and data can all do it now. DSQL is the piece that made the data layer fit, and its public endpoint with IAM is what quietly removes the most expensive line on a small AWS bill.

Check your own stack for the line that’s still there at 3am. Then decide whether you’re buying it on purpose.