SprintDay
A twenty-module project-management and bug-tracking product I built and shipped solo — then did the unglamorous work to make it production-grade. The interesting engineering wasn't any one feature. It was owning the whole vertical, then paying the self-hosting tax myself.
SprintDay is a Jira-style project-management and bug-tracking product: role-based teams, tickets with status / priority / severity / type / flags, sprints with burndown, a kanban board, rich-text comments with @mentions, real-time in-app notifications over WebSockets, file attachments to object storage, email invitations, an audit trail, a personal "My Day" planner with drag-and-drop, and reporting charts.
Twenty feature modules, front and back, built by one person. I mention that not for the count but for the shape of the challenge: breadth held coherently is its own engineering problem. A solo build sprawls unless you decide, up front, where the seams are — and then defend them.
Anyone can build the demo.
The engineering is what happens when you deploy it.
The app worked on my laptop. It was built against Neon — serverless Postgres — using Neon's serverless driver, and its schema had grown the fast way, through prisma db push: no migration files, just "make the database match the schema." Convenient. Then I decided to run it on my own hardware.
Moving to a standard, self-hosted Postgres broke three things at once — and none of them threw an error that told me which was which. The convenience I'd bought during development had quietly become a debt, and self-hosting was the day it came due.
A boot failure on a clean server is rarely one bug — it's several, stacked, each masking the next. I separated them instead of guessing, and the honest picture was three independent failures on the same path.
Neon's serverless adapter speaks its own wire protocol. It can't talk to a standard Postgres over TCP — so the app couldn't connect at all.
Because the schema had grown via db push, seven tables, a column, and an enum lived in the running schema but had no migration file. A database built from migrations came up missing them — the app and seed crashed on boot.
Prisma's migration runner auto-discovers its config — but wants the raw .ts. The tsc-compiled .js failed to parse, so migrations wouldn't run inside the container.
The migration drift was the one that mattered. The adapter and the config loader were mechanical — swap and re-point. But drift meant my schema's history was a fiction: the live database and the migration record disagreed about what the schema even was. You can't reproduce a database you can't describe.
The fastest fix and the right fix were not the same. Three shortcuts I rejected.
Stay on Neon in production
RejectedZero migration work — and zero learning. The point of self-hosting was to own the operational surface, including my own database. Dodging it would defeat the exercise.
Keep using db push against the server
RejectedIt would boot today and betray me on the next clean rebuild — the drift simply recurs. A production database you can't rebuild deterministically isn't production-grade.
Hand-write the missing migration from memory
RejectedError-prone and unverifiable. Prisma can compute the exact diff between the live schema and the migration state — trusting the tool over my memory is the whole reason the tool exists.
Fix the history first, then the plumbing — and separate what serves traffic from what changes the schema.
Recover the drift into a real migration
Let the tool compute the truth: diff the live schema against the migration state to generate the missing migration, then apply it via migrate deploy — so the seven tables are created owned by the app's own role, with correct privileges, not the superuser.
Swap the driver, move config out of the schema
Serverless adapter → the standard Postgres adapter, on both the app and the seed script. Under Prisma 7 the connection config no longer lives in the schema — it moved to the adapter, and the container ships the raw config file the migration runner expects.
Runtime image ≠ migration image
The image that serves traffic carries no migration tooling at all. Migrations and seeding run from a separate builder-stage image that keeps the CLI — a clean split that also became the single biggest lever on image size.
A production database
is one you can rebuild from nothing, on purpose.
Twenty modules, two clean state boundaries, one deterministic deploy.
The thing that keeps a solo product from sprawling is a small number of decisions applied everywhere: one domain module per concern; a strict split between UI state and server state on the client; and a deploy path where the code that runs never carries the code that migrates.
The image cut from 929MB to 391MB came from deleting specific packages a running app doesn't need. That's fast and it's fragile — and I'd rather show you the fragility than hide it.
Targeted package deletion for slimming
Zustand for UI, TanStack Query for server state
Shared Postgres, one DB + owning role per app
Boots from migrations on a clean self-hosted Postgres, with correct table ownership. Rebuildable from nothing, deterministically.
Running at sprintday.sukhcharan.dev, deployed via CI. Usage/adoption metrics are OPEN — not instrumented; I won't claim numbers I don't have.
Reported figures — 20 modules, ~60 commits across two repos, 929→391MB — are counted from the repos and build, not estimated. Runtime performance and user metrics are not yet instrumented and are stated as such.
db push and a serverless adapter are frictionless during development and become migration debt the moment you self-host. Use migrate dev from day one and the debt never accrues.Shipping and operating it taught me things the first build couldn't know. The honest revision.
Migrations from commit one; runtime ≠ migration image
Both are cheap early and expensive to retrofit. They're the difference between a demo and something you can operate.
Slim by choosing a lean base, not deleting packages
The fragile delete-list would become a smaller, stable base image and a proper prune step — the size win without the version fragility. It's already logged as the "better" in the ledger.
Instrument before you claim
The most honest section of this page is the one where I say the metrics are OPEN. Next time the health checks are already there — I'd wire request latency and error rates from the first deploy so "is it up, is it fast" is answerable from data, not vibes.