Skip to content
Work/Independent ProjectsIndependent · Solo
Product Engineering · Building from zero

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.sukhcharan.dev · live20 feature modulesAPI + web, 60 commitssolo · product → deploy
Role
Everything — solo, end-to-end
Stack
NestJS 11 · React 19 · Prisma 7 · Postgres · Socket.IO
Deploy
GH Actions → GHCR → self-hosted, Caddy HTTPS
Headline result
image 929MB → 391MB · measured
OwnershipProduct · SoloArchitecture · SoloImplementation · SoloOperations · SoloAI-assisted build · decisions mine
01 — What it is

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.

02 — The real problem

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.

03 — Investigation

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.

Fig 1 · Three failures on the boot path, unstacked
01
Wrong driver adapter

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.

02
Migration drift — the root cause

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.

03
Config loader mismatch

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.

Three failures, three different layers — driver, schema history, tooling. Naming them separately is what let me fix them in the right order instead of thrashing.

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.

04 — Alternatives considered

The fastest fix and the right fix were not the same. Three shortcuts I rejected.

Stay on Neon in production

Rejected

Zero 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

Rejected

It 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

Rejected

Error-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.

05 — Decision

Fix the history first, then the plumbing — and separate what serves traffic from what changes the schema.

1

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.

2

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.

3

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.

06 — Architecture

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.

Fig 2 · Client state split · API · deploy path
React 19 client · the state boundary I defend
Zustand
UI state only
TanStack Query
server cache only
server cache never leaks into the client store
NestJS 11 API · module per domain
JWT · RBAC · throttler · cron
authticketssprintscommentsuploadsnotifydaily-planorgsaudit-log+11 more
Socket.IO gateway → live notifications object storage → attachments, off-box Prisma 7 → self-hosted Postgres
Deterministic deploy · runtime image ≠ migration image
GitHub Actions
build + tag
GHCR
registry
Server pulls
391MB runtime
Caddy
auto-HTTPS
migrations + seed run from the builder-stage image, as a separate step — never inside the runtime image
The runtime image serves traffic and nothing else. Rollback is a tag; schema change is a separate, auditable step. That separation is the productionization win, not a detail.
07 — Trade-offs

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.

Chose

Targeted package deletion for slimming

The cost
The delete-list is matched to one exact dependency tree — version-fragile. Logged as deferred-decision #12 with an explicit revisit trigger: on any Prisma bump, re-verify the list and re-test that queries work, not just that the app boots.
Chose

Zustand for UI, TanStack Query for server state

Not buying
A single global store holding everything. Server cache in a client store goes stale and tangles invalidation — keeping the boundary strict is worth the two libraries.
Chose

Shared Postgres, one DB + owning role per app

Not buying
Split migration / runtime DB roles — deliberately deferred until a second consumer needs privilege separation. Named, not forgotten.
08 — Outcome
measured (repo / image)capability, not instrumented
Fig 3 · Deploy image size, before vs after
Container image ~58% ↓
929MB
391MB
Multi-stage build + moving migrations out of the runtime image + targeted slimming. Lighter to build, push, and pull onto a small cloud box.
Reproducible database

Boots from migrations on a clean self-hosted Postgres, with correct table ownership. Rebuildable from nothing, deterministically.

Shipped & live

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.

09 — Lessons learned
Serverless convenience is a loan. 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.
Unstack failures before you fix them. A boot crash was three independent bugs. Separating them by layer turned a panic into three ordered fixes.
Breadth is an architecture problem, not a stamina problem. Twenty modules stayed coherent because the seams — module-per-domain, UI-vs-server state — were decided once and enforced everywhere.
Write down the fragile decisions. The version-fragile slimming isn't a mistake because it's documented with a trigger. An undocumented shortcut is debt; a documented one is a plan.
10 — If I rebuilt this today

Shipping and operating it taught me things the first build couldn't know. The honest revision.

Keep

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.

Change

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.

Learned

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.

© 2026 Sukhcharan SinghCase study · SprintDay