The scariest part of building a solo product is not shipping features. It is the slowly accumulating weight of keeping a system running: the midnight alert, the manual backup rotation, the patch that can't wait until Monday. These aren't dramatic crises — they are small cognitive drains that quietly erode the motivation to keep building.
When we designed the infrastructure for Storyie, the guiding question was simple: how do we build something that operates itself? Not "less ops" — actually zero. No servers to patch, no databases to back up, no queues to monitor.
Six months in, here is what that looks like in practice, what trade-offs we made, and where the approach has limits.
TL;DR
- Storyie runs entirely on serverless infrastructure: Lambda (via SST v3), Supabase, and Cloudflare R2. Zero EC2 instances.
- SST v3 lets us write all of our AWS infrastructure — CloudFront, Lambda, S3, EventBridge crons — as TypeScript in a single config file.
- Supabase gives us PostgreSQL + Auth + Row Level Security in one service, removing the need for a separate auth system and application-layer authorization.
- R2 eliminates egress costs for image-heavy content.
- After six months, hands-on infra work has been: zero alerts, zero manual backups, and roughly one SST version bump per month.
Layer | Service | What it buys us |
|---|---|---|
Compute | Lambda via SST v3 | Next.js app + background jobs, no server management |
Database + Auth | Supabase | PostgreSQL, OAuth, RLS — one service, not three |
Image storage | Cloudflare R2 | S3-compatible, zero egress charges |
Background jobs | Lambda Cron (SST) | EventBridge + Lambda, no persistent worker |
Payments | Stripe | Webhook-native, fits Lambda naturally |
Resend | JSX templates via React Email | |
Push notifications | Firebase | Official Expo integration |
The overall shape of the system
User
↓
CloudFront (CDN + IP restrictions on staging)
↓
Lambda (Next.js on SST v3)
↓
┌──────────────┬──────────────┬──────────────────────┐
│ Supabase │ Cloudflare │ External services │
│ (PostgreSQL │ R2 │ (Stripe, Resend, │
│ + Auth │ (images) │ Firebase) │
│ + RLS) │ │ │
└──────────────┴──────────────┴──────────────────────┘
↑
Lambda Cron (10+ background jobs)Every piece is serverless. No instance IDs, no SSH keys, no patching schedule.
Design decision 1: SST v3 — IaC that doesn't feel like infrastructure work
Why SST
We wanted infrastructure as code, but Terraform and CDK carry overhead that is hard to justify for a small team. SST v3 lets us express AWS infrastructure in TypeScript, and deploying a Next.js app to Lambda looks like this:
new sst.aws.Nextjs("StoryieWeb", {
path: "./apps/web",
domain,
server: {
memory: "1024 MB",
runtime: "nodejs22.x",
architecture: "arm64",
timeout: "20 seconds",
},
});The arm64 setting is a small cost optimization — roughly 20% cheaper than x86 at the same memory allocation with no code changes required.
Customizing CloudFront without leaving the config
One of the practical wins with SST is that CloudFront customization — staging environment IP restrictions, disabling cache on auth routes — stays inside the same config file via the transform option:
transform: {
cdn: (args) => {
// OAuth callbacks must never be cached
const authCacheBehavior = {
pathPattern: "/api/auth/*",
cachePolicyId: cachingDisabledPolicy.id,
};
// ...
},
},The equivalent in raw Terraform would be a standalone CloudFront Distribution resource spanning 100+ lines. With SST, the infrastructure concern sits next to the application concern it serves.
What we actually get
sst deploybuilds and deploys the full production environment from scratch.sst devgives live reload of Lambda functions during local development.- CloudFront, Lambda, and S3 config all live in
sst.config.ts.
The trade-offs
SST v3 is built on Pulumi, which means there is a small lag when AWS introduces new features. The ecosystem is smaller than CDK, so when something breaks unexpectedly, the search results are thinner. Lock-in is real, but the underlying resources are standard AWS, so extraction is possible if priorities change.
Design decision 2: Supabase — delegate the database and the auth layer
PostgreSQL + Auth + RLS in one box
Building an auth system from scratch is a poor use of time for any product. Supabase gives us PostgreSQL, Google and Apple OAuth, and Row Level Security as a single managed service.
The RLS piece matters beyond convenience. With policies defined at the database level, every query — from any server action, API route, or background job — runs authorization checks automatically. There is no code path that can accidentally skip the check. The trade-off is that complex policy combinations become harder to debug, but the structural guarantee is worth that cost.
Type-safe access via Drizzle ORM
We access Supabase through Drizzle ORM rather than the Supabase client SDK directly. This gives us:
- Schema definitions that double as TypeScript types.
- Migrations managed via
drizzle-kit. - A shared
packages/databasepackage that both the Next.js app and Lambda functions import with full type safety.
See Building a Monorepo with pnpm and TypeScript for how the package boundaries work.
The trade-offs
The Supabase free tier caps PostgreSQL at 500 MB. Knowing when to move to a paid plan requires watching growth, not just shipping features. RLS policies can interact in non-obvious ways when they accumulate — ANDs and ORs between policies are implicit, which complicates debugging. And as with any managed service, Supabase's own outages are outside our control.
Design decision 3: Cloudflare R2 — S3-compatible, zero egress
A diary app surfaces images constantly — profile photos, inline pictures in entries. S3 charges egress on every byte served to readers. As user count grows, that line item scales against you directly.
R2 is S3-compatible. The migration was a credentials swap, not a code change. The decisive factor was egress: Cloudflare does not charge for data served from R2, which removes the "popular content costs more money" problem.
We use a presigned URL upload flow: the client requests a signed URL from the server, then uploads directly to R2. Image data never flows through Lambda, which keeps Lambda memory and timeout constraints out of the picture.
Design decision 4: background jobs as Lambda Cron
Storyie currently runs ten-plus scheduled jobs:
Job | Schedule | Purpose |
|---|---|---|
PerformanceAggregator | Daily 2:00 UTC | Aggregate performance metrics |
ViewAggregator | Daily 3:00 UTC | Aggregate view counts |
LikeAggregator | Daily 4:00 UTC | Aggregate like counts |
DiaryReminderNotifier | Daily 12:00 UTC | Push diary reminder notifications |
EmailQueueProcessor | Every 5 minutes | Drain the outbound email queue |
WeeklySummaryEmailSender | Weekly Sunday | Send weekly summary emails |
XPostScheduler | Daily | Schedule automated X posts |
All of them are sst.aws.Cron components, which deploy as EventBridge rules + Lambda functions:
new sst.aws.Cron("DiaryReminderNotifier", {
schedule: "cron(0 12 * * ? *)",
job: {
handler: "packages/jobs/src/handlers/notifications/diaryReminder.handler",
runtime: "nodejs22.x",
timeout: "5 minutes",
},
});The alternative — a Celery-style worker with Redis — would introduce its own monitoring surface: is the queue draining? is the worker healthy? Lambda Cron means we only have to care about jobs that fail, not about a persistent process that needs to stay alive.
Design decision 5: external services for everything else
"Not building it" is a design decision too.
Domain | Service | Reason |
|---|---|---|
Payments | Stripe | Webhook-based, pairs naturally with Lambda |
Resend | React Email lets us write templates as JSX | |
Push notifications | Firebase | Official Expo integration |
DNS | Cloudflare | R2 integration + fast propagation |
All of these are API-based. They reach Lambda via HTTP or WebSockets. Credentials go in as SST environment variables. No servers to maintain.
What six months of operation actually looked like
Hands-on infrastructure work
To be specific about what actually required attention:
- SST minor version bumps — roughly monthly, no incidents.
- Schema migrations —
drizzle-kit pushon schema changes. This is application development, not operational work. - CloudFront cache invalidation on deploy — SST handles this automatically.
Zero midnight alerts. Zero manual database backups — Supabase handles those. Zero server patching.
Cost
At Storyie's current scale:
- AWS (Lambda + CloudFront + S3): $5–15/month
- Supabase Pro: $25/month
- Cloudflare R2: free tier
- Resend: free tier
- Firebase: free tier
Total: $30–40/month. A self-managed VPS might cost less in raw terms, but the operational load — monitoring, updates, backups, incidents — is a hidden tax that compounds. The managed stack has a higher nominal price and a dramatically lower cognitive one.
Where this architecture doesn't fit
- Applications requiring persistent WebSocket connections.
- Heavy compute workloads (ML inference, video transcoding).
- Use cases where Lambda cold-start latency (a few hundred milliseconds) is genuinely user-visible.
For Storyie — a content-forward diary app — cold starts are not perceptible in practice. But teams building real-time collaboration tools or latency-sensitive APIs should reach for Fargate or ECS instead.
The actual lesson: choosing what not to own
Zero-ops infrastructure is not about picking managed services uncritically. It is the deliberate practice of deciding what your team will not be responsible for — and then designing the rest of the system around those choices.
- Database → Supabase owns it; in exchange, authorization logic lives at the database layer via RLS, not scattered through application handlers.
- Deployment → SST owns it; in exchange, there is an IaC learning curve and some Pulumi-specific behavior to understand.
- Storage → R2 owns it; in exchange, upload flows use presigned URLs with the complexity that entails.
None of these are free. Each "we don't manage this" choice transfers some design responsibility back to the application. The point is not to eliminate complexity — it is to concentrate complexity where you can actually control it, and eliminate the operational kind that produces no product value.
After six months: zero infra-caused downtime. For a solo-maintained production system, that is the result that matters.
Related Posts
- Building a Monorepo with pnpm and TypeScript — how shared packages like
@storyie/databasework across Lambda and the Next.js app - Deploying Next.js to AWS with SST — a closer look at our SST deployment configuration
Try Storyie
Storyie runs on the infrastructure described in this post. You can write your first diary entry at storyie.com, or grab the iOS app if you prefer mobile.