../

Building the MVP

Phase 3 of the pipeline: turning the signed-off spec from product design into a working product at a public URL in 2–4 weeks (hard cap 6), then handing it to launch & iterate. It is opinionated for a TypeScript builder: the stack details live on Next.js, Hono, PostgreSQL, Drizzle, authentication and testing. This sheet is about what to build, what not to, and how to keep moving.

Phase at a glance

ItemPhase 3: Build the MVP
Pipeline positionIdeation → Problem validation → Solution design → Build → Launch → Iterate
Goalthe core loop working end to end for a stranger, in production, instrumented
Timebox2–4 weeks, hard cap 6 (the circuit breaker: at week 6, stop and reshape, don't extend)
Inputone-page spec, story map with MVP slice, tested prototype, event plan, "not now" list
Artifactsdeployed app at a public URL, schema and migrations, event tracking, error monitoring, smoke tests, runbook notes, pre-launch checklist, launch list
Outputa product you'd be comfortable showing 20–50 hand-picked users next week
Main trapsover-engineering, bikeshedding the stack, building auth from scratch, infrastructure procrastination, polishing before users

A 4-week plan

WeekFocusDone when
1walking skeleton deployed on day 1; auth; schema; happy path of the core loop, uglyyou can complete the core loop in production with your own account
2core loop complete (edge cases that lose data or money); first-run flow and empty statesa new account reaches the aha moment without help
3payments, transactional email, instrumentation, error tracking; the rough edges on the core looptest payment succeeds; events arrive; a thrown error pages you
4pre-launch checklist; 2–3 friendly users try it while you watch; fix what they hitevery checklist item ticked; launch list ready

A 2-week build compresses weeks 1–2 and 3–4. If week 4 ends with the core loop still broken, cut scope, not the launch.

Entry criteria

Phase 3 starts with the Phase 2 handoff package from product design. Missing pieces are cheaper to fix now than mid-build.

Must haveCheck
Signed-off one-page specsuccess metric, appetite in weeks, scope in/out, rabbit holes decided
Story map with a single MVP slicewalking skeleton identified; nothing in the slice off the core loop, first run or payment
Prototype tested with ≥ 5 target usersseverity 3–4 issues fixed in the design
Data model sketchtenant, core object lifecycle, events table
Analytics event plan5–10 object_action events; activation and core-loop definitions
"Not now" listwith the manual workaround for each
Stack decisionmade in under a day, using the default below unless there's a specific reason

Principles

PrincipleWhat it means in practiceSource
Walking skeleton on day 1the thinnest end-to-end slice (page → API → DB → deploy) live at a real URL before any feature workAlistair Cockburn's term: a tiny implementation that links the main architectural components end to end
Choose boring technologyuse tools whose failure modes you already know; spend novelty only where it's the productDan McKinley, "Choose Boring Technology" (2015)
Monolith firstone codebase, one deploy, one database; split only when a real boundary hurtsMartin Fowler, "MonolithFirst" (2015)
Do things that don't scale, in code toomanual operations behind a clean UI; admin by SQL; onboarding by handPaul Graham, "Do Things That Don't Scale" (2013), applied to engineering
Vertical sliceseach change delivers user-visible behavior through every layerstory mapping (product design)
Ship to production dailysmall diffs, deployed on merge, unfinished work behind flagstrunk-based development
Fixed time, variable scopethe deadline holds; scope is what movesRyan Singer, Shape Up (2019)

Choose boring technology

McKinley's essay (March 2015) proposes that "every company gets about three innovation tokens": each new, unfamiliar technology spends one, because its operational unknowns are paid for long after the build. He argues boring tools (Postgres, a mainstream language) win because their failure modes are well understood, and that before adding anything you should consider how you'd solve the problem without adding anything new.

For a solo founder the budget is smaller: spend at most one token, and only on the thing that is the product. If the product is a scheduling tool, the scheduling logic may deserve novelty; the database, auth, queue and hosting do not.

Innovation token spent on…Verdict for an MVP
a new framework you wanted to learnno; learn it on a side project
a new database (graph, document, vector-only)no, unless the product is that query pattern; Postgres covers JSON, full-text and vectors (pgvector)
a new language or runtime you haven't shippedno
a novel algorithm that is the product's advantageyes, this is where the token goes
an AI model integration that is the core valueyes, but wrap it behind one function so you can swap providers

Monolith first

Fowler (June 2015) reports two observations: "Almost all the successful microservice stories have started with a monolith that got too big and was broken up", and "Almost all the cases where I've heard of a system that was built as a microservice system from scratch, it has ended up in serious trouble." For an MVP there's also no team to split services between. Organize the monolith in modules by domain (billing/, rota/, accounts/) so a later split is possible, and stop there.

Do things that don't scale, in code

Instead of building…Do this for the first 50 customers
an admin dashboarda database GUI (TablePlus, DBeaver, Drizzle Studio) and a file of saved SQL queries
self-serve data importask for their spreadsheet and import it yourself with a script
automated onboarding emailssend them yourself from your own inbox; you learn more
a support ticketing systema shared inbox or a feedback email address
a rules engine or settings pagehard-code the defaults; change a customer's config in SQL on request
automated refunds, plan changesthe payment provider's dashboard or hosted customer portal
reporting and exportsrun a query and email them a CSV
a moderation queuea daily query of new content, reviewed by you

The rule: automate the second or third time the same manual task annoys you, not the first time you imagine it.

Build, buy or no-code

CapabilityBuildBuy (managed service / library)No-codeMVP default
Core loop logic✓—only if the loop is simple CRUD and you're testing demandbuild: it's the product
Authenticationonly sessions via a vetted librarymanaged auth or an auth library that stores users in your DB—buy
Payments and billingnever card handlinghosted checkout + customer portalpayment linksbuy
Transactional email—email API provider—buy
Marketing site / landing pageif it's the same Next.js appsite builder✓same app or no-code; don't spend a week on it
Admin / back office—DB GUIinternal-tool buildersDB GUI + SQL
SearchPostgres full-texthosted search—Postgres until it hurts
File storage—S3-compatible object storage—buy
Background jobsa jobs table + a worker, or platform cronmanaged job queue—simplest thing your host supports
Analyticsan events tableproduct analytics tool—both: own the raw events, use a tool for dashboards
Docs / help center——a notes tool published to the webno-code

Decision rule: build only what differentiates the product or what no vendor does acceptably. Everything else is a purchase, and the cost of switching later is almost always lower than the cost of building now.

A default stack

Pick in an afternoon; changing later is cheaper than debating now. Vendor names are examples, not endorsements or price claims; check current pricing and terms yourself.

LayerDefault for this ownerAlternativesChoose the alternative when
App frameworkNext.js (App Router) full-stack: pages, server actions, route handlersVite + React SPA with a Hono API on BunAPI-first product, a mobile client, or you dislike framework magic
LanguageTypeScript, strict——
Runtime / package managerBun locallyNode in production if the host prefers itthe host or a dependency needs Node
DatabasePostgreSQL, managed (e.g. Neon, Supabase, or your host's managed Postgres)SQLite / libSQLsingle-server, read-heavy, tiny data
ORM / queriesDrizzle + drizzle-kit migrationsKysely, Prisma, raw SQLteam already knows another
Authmanaged or library, not hand-rolled: e.g. Clerk, WorkOS AuthKit, Auth0 (hosted); Better Auth, Auth.js (library, users in your DB)sessions you write using the authentication sheetyou need full control and you know the pitfalls
UITailwind + a copy-in component library (e.g. shadcn/ui)your own minimal components—
PaymentsStripe Checkout + Customer Portala merchant of record (e.g. Paddle, Lemon Squeezy) that handles sales tax and VAT for youyou sell to consumers in many tax jurisdictions and don't want the tax admin
Transactional emailan email API (e.g. Resend, Postmark, Amazon SES)——
HostingVercel for Next.js; Fly.io, Railway or Render for a Bun/Hono servera single VPS with Dockeryou're comfortable running a box
Object storageS3-compatible (e.g. AWS S3, Cloudflare R2)——
Error trackinge.g. Sentryhost's built-in logs—
Product analyticsown events table + a tool such as PostHog; privacy-first page analytics such as Plausible——
Uptimeany external HTTP monitor on /healthz——
Feature flagsenv var + DB table (recipe)the analytics tool's flagsmany flags, percentage rollouts

Rules for the stack:

  • Use what you already ship with. The best stack is the one you can debug at 11 pm.
  • One language end to end. TypeScript on client and server; share types and validation schemas.
  • Managed over self-hosted for anything stateful (database, auth, email, files).
  • No stack changes after day 2 of the build. Write the idea down for the iteration phase.

The MVP architecture

One repository, one deploy, one database. Everything else is a managed service called over HTTPS.

                        ┌───────────────────────────────┐
  browser ─── HTTPS ───►│  ONE APP (single repo/deploy) │
  (responsive web)      │  Next.js  or  Vite + Hono     │
                        │  ├─ pages / UI                │
                        │  ├─ API routes / actions      │
                        │  ├─ modules: accounts, core,  │
                        │  │   billing, notifications   │
                        │  ├─ track() → events table    │
                        │  └─ /healthz                  │
                        └──────┬──────────┬─────────────┘
                               │          │
              ┌────────────────┘          └──────────────────┐
              ▼                                              ▼
   ┌─────────────────────┐        managed services (HTTPS APIs)
   │ ONE POSTGRES        │        ├─ auth provider / library
   │ tables + events     │        ├─ payments (webhooks → app)
   │ daily backups       │        ├─ email API
   │ (PITR if offered)   │        ├─ object storage (files)
   └─────────────────────┘        ├─ error tracking
                                  └─ product analytics
 
 CI: push to main → typecheck + smoke tests → migrate → deploy
 Monitoring: uptime check on /healthz, error alerts to phone
Deliberately absentWhyAdd when
Redis / cache layerPostgres is fast enough for early loadmeasured slow queries you can't index away
Message brokera jobs table or platform cron covers itjob volume or fan-out a table can't handle
Separate API serviceone deploy is simpler to reason abouta second client (mobile, public API) needs a stable contract
Kubernetes, service meshnothing to orchestratemultiple services with a team to run them
Multi-regionlatency isn't your problem yetcustomers complain, with numbers

Scaling, caching and queues when you really need them: system design.

What not to build

The list of things that feel productive and aren't. Each one is a week you don't get back.

Don't buildWhy it's temptingDo instead
Custom authentication"it's just a users table and bcrypt"managed auth or a vetted library; see authentication for why it's never "just" that (reset flows, sessions, rate limiting, MFA, account enumeration)
Microservices"it'll scale"modular monolith
Kubernetes / infra-as-code empirerésumé-driven, feels like engineeringa PaaS and a git push
A design systemcomponents feel reusablea component library, one type scale, one accent color
An admin dashboard"I'll need it eventually"DB GUI + saved SQL
Internationalization"we'll go global"one language; keep strings out of logic so i18n is possible later
Perfect test coveragefeels responsiblea handful of end-to-end smoke tests on the core loop + unit tests on money and permissions
Multi-tenancy abstractions"enterprise customers"an account_id column on every table, filtered in every query; nothing more
Roles and permission matrices"teams will need it"owner + member
Settings pages"users want control"good defaults; change it for them in SQL
A public API"developers will integrate"webhooks or CSV export when a customer asks
Real-time everythingWebSockets are funpoll or refresh; real-time only if it is the core loop
Your own billing logic"it's just subscriptions"hosted checkout, hosted customer portal, webhooks
Mobile apps"users are on phones"responsive web; a PWA at most
Caching layerspremature performanceindexes; measure first
A plugin/extension system"flexibility"hard-code the three cases you know
Dark mode, themes, animationspolishone theme; defer

Quality bar: solid vs rough

Not everything deserves the same care. Get the irreversible things right; let the reversible things be ugly.

Must be solid (hard or impossible to undo)WhyMinimum
Data integritylost or corrupted data loses the customer and their trustforeign keys, not null, unique constraints, transactions for multi-row writes, migrations in version control
Auth and access controla data leak between customers can end the companyvetted auth; every query scoped by account_id; test that user A can't read user B's data
Paymentsdouble charges, missed webhooks, wrong access after cancelprovider-hosted checkout; idempotent webhook handler; entitlement derived from provider state
Backups and restoreyou will delete production data onceautomated daily backups (or provider PITR) and one practiced restore
Secretsleaked keys are expensive and instantenv vars, never committed; rotate on leak
Email deliverabilitysign-in links and receipts must arriveSPF, DKIM and DMARC set up on your sending domain
Can be roughWhy it's fine
visual polish, animations, illustrationsearly users forgive plain if it solves their problem
performance beyond "feels fast" on the core loopyou have tens of users
code structure, duplicationyou'll rewrite parts once you know what matters
edge cases that don't lose data or moneyhandle them manually when they happen
admin toolingSQL
mobile layout of secondary screenscore loop on mobile must work; settings can be cramped
error messages on rare pathsa generic message plus error tracking

Testing strategy for MVPs

Aim for confidence that the core loop works after every deploy, not coverage.

LayerWhat to testHow manyTool
Typeseverything, for free—tsc --noEmit in CI, strict mode
End-to-end smokesign up → first value; core loop; payment (test mode)3–5 testsPlaywright (E2E with Playwright)
Unitmoney calculations, permission checks, date/time logic, anything with branches you'd get wrongas neededbun test or Vitest
Integrationwebhook handlers (payments), tenant isolation ("A can't see B")a fewreal test database, not mocks
Manuala checklist run before each launch step1 listyou, on your phone

Skip for now: snapshot tests, component tests of simple UI, coverage thresholds, load tests. Add a regression test whenever a bug reaches a user. Details on runners and patterns: testing.

Security minimums

Small products get attacked by the same bots as big ones. The OWASP Top 10:2025 (opens in a new tab) is the checklist; for an MVP, these are the non-negotiables.

AreaMinimumOWASP 2025 category
Access controlevery query filtered by tenant and owner on the server; never trust IDs from the client; deny by defaultA01 Broken Access Control
Configurationproduction debug off; default credentials changed; security headers (e.g. Hono's secureHeaders middleware)A02 Security Misconfiguration
Dependencieslockfile committed; --frozen-lockfile in CI; update regularly; check that an AI-suggested package really exists before installingA03 Software Supply Chain Failures
Transport and cryptoHTTPS everywhere (hosts do it by default), HSTS; never roll your own cryptoA04 Cryptographic Failures
Injectionparameterised queries (Drizzle does this; beware raw sql string building); validate input with a schema (Zod)A05 Injection
Authenticationvetted auth; rate-limit login, sign-up and password reset; HttpOnly, Secure, SameSite cookiesA07 Authentication Failures
Integrityverify payment webhook signaturesA08 Software or Data Integrity Failures
Logging and alertinglog auth events and errors; alert on error spikesA09 Security Logging and Alerting Failures
Errorsfail closed; don't leak stack traces to usersA10 Mishandling of Exceptional Conditions

Plus:

  • Secrets: in the host's secret store and a local .env that is in .gitignore. Turn on your Git host's secret scanning.
  • Least privilege: the app connects as a database role that is not a superuser; production and development use separate databases and separate keys; your personal accounts have MFA.
  • Rate limiting: on anything that sends email or SMS, costs money (AI calls), or accepts anonymous input (waitlist, contact form). See rate limiting.
  • Privacy: collect the minimum personal data; know where it's stored; publish a privacy policy that matches reality.

Instrumentation from day one

If you can't see what users do in week one of launch, you can't learn anything from the launch. Instrumentation is part of the feature, not a follow-up.

WhatMinimumWhy
Product eventsthe 5–10 events from the event plan, tracked server-side into your own events table (recipe); optionally mirrored to an analytics toolactivation, core loop, retention cohorts
Page analyticsa lightweight script on marketing pageswhich channels bring visitors
Error monitoringan error tracker on client and server, with alerts to your phoneyou find bugs before users report them (most won't)
Uptimean external monitor hitting /healthz every minute or soyou know it's down before a customer does
Logsstructured logs (JSON) with request IDs, retained somewhere searchabledebugging the one weird account
Business metricsa saved SQL query: sign-ups, activated, paying, per weekthe Monday review in launch & iterate

Event naming

RuleGoodBad
object_action, snake_case, past tenseinvoice_sentSendInvoice, clicked_button
name the domain object, not the UIproject_createdmodal_submit
properties carry detailinvoice_sent {amount, currency}invoice_sent_over_100
one name per meaning, foreversignup_completedrenaming to user_registered in week 3
track outcomes server-side, where they're truepayment_succeeded from the webhookfrom the "thank you" page load

Typical MVP set: account_created, onboarding_step_completed, first-value event (e.g. report_generated), core-loop events (2–3), invite_sent, checkout_started, subscription_activated, subscription_cancelled, feedback_submitted. Retention is computed from the core-loop events, not from logins.

Feature flags and shipping daily

PracticeHowWhy
Trunk-basedshort branches (hours, not days), merge to mainno integration hell; always releasable
Deploy on mergeCI deploys main to production (recipe)every day ends with something live
Flags for unfinished workwrap incomplete features in a flag that defaults offmerge half-built work without exposing it
Flags per customerturn a feature on for one account to test with themconcierge beta, pilot customers
Kill switchesa flag that disables an expensive or risky pathturn off a broken feature without a deploy
Remove flagsdelete the flag within a week or two of full rolloutflags are debt; stale ones confuse
Migrationsbackwards-compatible (add column → deploy → backfill → switch → drop later)deploy and roll back without downtime

Working rhythm

RuleDetail
Vertical sliceseach task goes UI → API → DB → deployed; never "do the whole database layer first"
WIP limit 1one task in progress at a time; finish or cut before starting another
The 2× ruleif a task takes more than twice its estimate, stop: cut it, simplify it, or do it manually
Timebox research30–60 minutes to choose a library; then pick the most popular adequate option
Yak-shaving alarmif you're three problems removed from the task (fixing a build tool to fix a plugin to add a feature), stop and ask whether the feature needs the plugin
Daily deploysomething reaches production every working day
Stop at a known next stepend the day mid-task with a note, so tomorrow starts fast
Weekly scope checkevery Friday: is the remaining MVP slice still achievable by the deadline? If not, cut now
THE DAILY LOOP (solo founder, build phase)
 
09:00  Read yesterday's note. Pick ONE story from the MVP slice.
09:15  Write the acceptance check (what will I click to prove it?)
09:30  Build the thinnest vertical slice of it.
12:30  Deploy what works (behind a flag if incomplete).
13:30  Continue / fix / cut (2× rule).
16:30  Deploy. Tick the story on the map. Update the cut list.
16:45  Write tomorrow's first step. Reply to any user messages.
17:00  Stop.
 
Weekly: Friday scope check · show it to 1 target user · review
error tracker · update launch list.

Building with AI coding tools

AI coding assistants genuinely speed up an MVP: scaffolding, CRUD, forms, migrations, tests, glue code, and unfamiliar APIs. They also generate plausible code faster than you can understand it. Use them like a fast junior developer whose work you are responsible for.

DoDon't
scaffold boilerplate, forms, CRUD routes, schema draftslet it choose your stack or architecture
ask for small, reviewable diffs (one story at a time)accept a 2,000-line change you haven't read
read every line that touches auth, payments, permissions, or data deletiontrust it on security-sensitive code
give it your conventions (a short project instructions file, examples of existing code)let each session invent new patterns
have it write tests for the behavior you specify, then check the tests assert the right thinglet it write tests that mirror its own bugs
verify every new dependency exists and is the package you meantpaste in packages it names without checking
run the app and click through the changeassume "it compiles" means "it works"
keep secrets out of prompts and pasted logspaste .env contents to debug

The failure mode is not bad code; it's a codebase you no longer understand two weeks before launch. Keep diffs small, commit often, and stay able to explain every module.

Friction points and fixes

Friction pointSymptomFix
Stack paralysisa week of comparing frameworks, ORMs, hostsuse the default stack above; you have one afternoon; the choice matters far less than shipping
Scope creep"while I'm here" features; the MVP slice growsanything new goes on the "not now" list; to add a story, remove one of equal size
Perfectionismrefactoring working code, polishing screens nobody has seenthe quality bar table: only the "must be solid" column deserves polish now
Infrastructure procrastinationDocker, Terraform, CI matrices, monorepo tooling before a feature existsPaaS + one workflow file; infrastructure work capped at half a day in week 1
Building auth from scratchthree days in, now on password reset emailsstop; use managed auth or a library; you'll be done by lunch
Horizontal buildingfull schema, then full API, then full UI; nothing works until week 4walking skeleton first; vertical slices only
Rewrite urge"the code is a mess, I should restart"no rewrites before launch; messy code with users beats clean code without
Learning-driven developmenttrying the new framework, runtime or database "while I'm at it"innovation-token rule: none spent outside the product's core
Estimation driftweek 6 arrives, still "almost done"the circuit breaker: at the hard cap, cut to what works and launch it, or go back to Phase 2
Invisible progressnobody has seen it in three weeksshow one target user every Friday; deploy daily
Polishing before usersonboarding animations, marketing copy, logodefer; the first 20 users are hand-held anyway
Edge-case paralysishandling every timezone, currency and malformed inputhandle what loses data or money; log and hand-fix the rest

Pre-launch checklist

Run it in week 4. Every item is small; skipping them is how launches embarrass.

PRE-LAUNCH CHECKLIST
 
DOMAIN & DELIVERY
[ ] Custom domain; apex and www redirect to one canonical URL
[ ] HTTPS everywhere (valid certificate, HSTS)
[ ] Email: SPF, DKIM, DMARC on sending domain; magic links and
    receipts land in inbox, not spam (test Gmail and Outlook)
 
LEGAL & TRUST
[ ] Privacy policy that matches what you actually collect
[ ] Terms of service
[ ] Cookie/consent banner if your analytics or region needs one
[ ] Contact method visible (email address, not only a form)
[ ] Company/legal name where payment or law requires it
 
PRODUCT
[ ] Onboarding: new account reaches first value without help;
    empty states explain the next action
[ ] Core loop works end to end for a fresh account (not yours)
[ ] Account deletion or a documented way to request it
[ ] Mobile check: core loop usable on a real phone
[ ] Performance: key pages load fast on a mid-range phone on 4G;
    no obvious slow queries on the core loop
[ ] 404 and error pages exist and link home
 
PAYMENTS
[ ] Test-mode purchase, cancellation and failed payment work
[ ] Webhook signature verified; handler idempotent
[ ] One real live-mode purchase (yours), refunded
[ ] Receipts/invoices sent
 
OPERATIONS
[ ] Analytics: all planned events arriving, with properties
[ ] Error tracking live on client + server; alerts reach phone
[ ] Uptime monitor on /healthz
[ ] Backups automated AND one restore rehearsed
[ ] Secrets only in the host's secret store; .env not in git
[ ] Rate limits on sign-up, login, email-sending endpoints
 
SEO & SHARING BASICS
[ ] <title> and meta description per public page
[ ] Open Graph image and tags (link previews look right)
[ ] sitemap.xml and robots.txt; favicon
 
FEEDBACK
[ ] Feedback widget or "Reply to this email" on key emails
[ ] A way to book a call with you (calendar link)

Exit criteria / handoff

Phase 3 ends, and Phase 4: launch begins, when all of these are true. Anything not on this list is not a launch blocker.

PHASE 3 EXIT CHECKLIST (handoff to Phase 4: Launch)
 
[ ] MVP deployed at a public URL on your own domain
[ ] A brand-new user completes the core loop end to end in
    under N minutes (N = the first-value target in the spec,
    typically 5-10), without help, on desktop and phone
[ ] Analytics live: every event in the plan arrives with its
    properties; activation query written and returns numbers
[ ] Error tracking and uptime monitoring live, alerting you
[ ] Payments work in live mode (if charging from day one)
[ ] Feedback channel in the product (widget, email, call link)
[ ] Backups automated and one restore rehearsed
[ ] Pre-launch checklist complete
[ ] Launch list of the first 20-50 named people to invite:
    interviewees, prototype testers, waitlist, warm intros
    (name, how you know them, channel, date to contact)
[ ] "Not now" list updated: everything cut during the build,
    ready to become the iteration backlog
Handoff artifactUsed in Phase 4/5 for
public URL + working core loopsoft launch to the interview list
events table and activation querythe first cohort table and activation rate
error trackingfixing what the first users hit, same day
launch listthe first invitations, sent personally
"not now" listthe starting backlog, re-prioritized with real usage data

Recipes

Drizzle schema: accounts, users, events

Tenant, user, append-only event log and feature flags. Assumes casing: "snake_case" in the Drizzle config and client, as on Drizzle.

src/db/schema.ts
import { sql } from "drizzle-orm";
import {
  boolean, index, jsonb, pgTable, text, timestamp, uuid,
} from "drizzle-orm/pg-core";
 
const createdAt = () =>
  timestamp({ withTimezone: true }).notNull().defaultNow();
 
export const accounts = pgTable("accounts", {
  id: uuid().primaryKey().defaultRandom(),
  name: text().notNull(),
  createdAt: createdAt(),
});
 
export const users = pgTable("users", {
  id: uuid().primaryKey().defaultRandom(),
  accountId: uuid()
    .notNull()
    .references(() => accounts.id, { onDelete: "cascade" }),
  email: text().notNull().unique(),
  createdAt: createdAt(),
});
 
export const events = pgTable(
  "events",
  {
    id: uuid().primaryKey().defaultRandom(),
    accountId: uuid().references(() => accounts.id, {
      onDelete: "set null",
    }),
    userId: uuid().references(() => users.id, {
      onDelete: "set null",
    }),
    name: text().notNull(),
    props: jsonb().$type<Record<string, unknown>>()
      .notNull()
      .default({}),
    at: createdAt(),
  },
  (t) => [
    index("events_name_at_idx").on(t.name, t.at),
    index("events_user_at_idx").on(t.userId, t.at),
  ],
);
 
export const flags = pgTable("feature_flags", {
  name: text().primaryKey(),
  enabled: boolean().notNull().default(false),
  accountIds: uuid().array().notNull().default(sql`'{}'`),
});

Event-tracking helper

Server-side, typed event names, never throws into the request that called it.

src/lib/track.ts
import { db } from "../db";
import { events } from "../db/schema";
 
export type EventName =
  | "account_created"
  | "first_value_reached"
  | "core_action_completed"
  | "checkout_started"
  | "subscription_activated"
  | "feedback_submitted";
 
type Actor = { accountId?: string; userId?: string };
 
export async function track(
  name: EventName,
  actor: Actor,
  props: Record<string, unknown> = {},
) {
  try {
    await db.insert(events)
      .values({ name, ...actor, props });
  } catch (err) {
    console.error("track failed", name, err);
  }
}
 
// await track("account_created", { accountId, userId },
//   { source: "show_hn" });

Activation rate by sign-up week, straight from the table:

select date_trunc('week', a.at) as week,
       count(*) as signups,
       count(v.account_id) as activated,
       round(100.0 * count(v.account_id) / count(*), 1)
         as pct
from events a
left join lateral (
  select e.account_id from events e
  where e.account_id = a.account_id
    and e.name = 'first_value_reached'
    and e.at < a.at + interval '7 days'
  limit 1
) v on true
where a.name = 'account_created'
group by 1 order by 1;

Feature-flag helper

Env var for global switches, a DB row for per-account rollouts. FLAGS=new_editor,bulk_import turns flags on for everyone.

src/lib/flags.ts
import { eq } from "drizzle-orm";
import { db } from "../db";
import { flags } from "../db/schema";
 
const fromEnv = new Set(
  (process.env.FLAGS ?? "")
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean),
);
 
export async function isOn(
  name: string,
  accountId?: string,
) {
  if (fromEnv.has(name)) return true;
  const [row] = await db
    .select()
    .from(flags)
    .where(eq(flags.name, name))
    .limit(1);
  if (!row) return false;
  if (row.enabled) return true;
  return !!accountId && row.accountIds.includes(accountId);
}
 
// enable for one pilot customer:
// update feature_flags
//   set account_ids = array_append(account_ids, '<uuid>')
//   where name = 'bulk_import';

Health-check route in Hono

Point the uptime monitor here. Returns 503 if the database is unreachable.

src/routes/health.ts
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { db } from "../db";
 
export const health = new Hono();
 
health.get("/healthz", async (c) => {
  const version = process.env.GIT_SHA ?? "dev";
  try {
    await db.execute(sql`select 1`);
    return c.json({ ok: true, db: "up", version });
  } catch {
    return c.json({ ok: false, db: "down", version }, 503);
  }
});
 
// app.route("/", health);

Waitlist form handler

A plain HTML form posting to Hono; a hidden honeypot field catches naive bots; duplicates are ignored.

src/routes/waitlist.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import {
  pgTable, text, timestamp,
} from "drizzle-orm/pg-core";
import { db } from "../db";
 
export const waitlist = pgTable("waitlist", {
  email: text().primaryKey(),
  source: text(),
  at: timestamp({ withTimezone: true }).defaultNow(),
});
 
const Form = z.object({
  email: z.string().trim().toLowerCase().email(),
  source: z.string().max(64).optional(),
  website: z.string().max(0).optional(), // honeypot
});
 
export const waitlistRoutes = new Hono().post(
  "/waitlist",
  zValidator("form", Form),
  async (c) => {
    const { email, source } = c.req.valid("form");
    await db
      .insert(waitlist)
      .values({ email, source })
      .onConflictDoNothing();
    return c.redirect("/thanks", 303);
  },
);
landing.html (form)
<form method="post" action="/waitlist">
  <label>Email <input name="email" type="email"
    autocomplete="email" required></label>
  <input name="website" tabindex="-1" autocomplete="off"
    style="position:absolute;left:-9999px"
    aria-hidden="true">
  <input type="hidden" name="source" value="landing">
  <button>Join the waitlist</button>
</form>

Add a rate limit on this route before you post the link anywhere public.

Deploy on push

GitHub Actions: typecheck, test, migrate, deploy to Fly.io on every push to main. Swap the last step for your host's CLI; Vercel and Render can also deploy from Git without a workflow.

.github/workflows/deploy.yml
name: deploy
on:
  push:
    branches: [main]
concurrency: deploy-production
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bunx tsc --noEmit
      - run: bun test
      - run: bunx drizzle-kit migrate
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

Migrations run before the new code deploys, so they must be backwards-compatible with the running version.

Nightly Postgres backup

For a self-managed database or as a second copy alongside your provider's backups. Custom format, 14-day retention, and a sanity check that the file is restorable.

~/bin/pg-backup
#!/usr/bin/env bash
set -euo pipefail
dir="$HOME/backups/pg"
file="$dir/app-$(date -u +%Y%m%dT%H%MZ).dump"
mkdir -p "$dir"
 
pg_dump --format=custom --no-owner \
  --dbname="$DATABASE_URL" --file="$file"
pg_restore --list "$file" > /dev/null   # readable?
 
# copy off the machine, e.g. to object storage:
# aws s3 cp "$file" "s3://my-backups/pg/"
find "$dir" -name 'app-*.dump' -mtime +14 -delete
crontab -e
# 03:15 UTC daily; the script's env needs DATABASE_URL
15 3 * * * ~/bin/pg-backup >> ~/pg-backup.log 2>&1
restore (rehearse this once)
createdb app_restore_test
pg_restore --no-owner --dbname=app_restore_test \
  ~/backups/pg/app-20260926T0315Z.dump
psql app_restore_test -c "select count(*) from users;"

Use a pg_dump at least as new as the server. More on backups and point-in-time recovery: PostgreSQL.

References