Designing infrastructure you never have to operate: Storyie's zero-ops stack

Storyie Engineering Team
9 min read

How we built Storyie on SST v3, Supabase, and Cloudflare R2 to eliminate server management, DB backups, and on-call pages — and what six months of running it actually looked like.

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

Email

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 deploy builds and deploys the full production environment from scratch.
  • sst dev gives 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/database package 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

Email

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:

  1. SST minor version bumps — roughly monthly, no incidents.
  2. Schema migrationsdrizzle-kit push on schema changes. This is application development, not operational work.
  3. 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

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.

Available on iOS & Android

Download on the App StoreGet it on Google Play