Two threads ran in parallel today. On the Homelab side, the monthly restore-test suite found a real bug on its first run — wazuh-agents had been silently invalidating every host because of an XML check that didn’t understand Wazuh’s multi-root ossec.conf format. That’s covered.
The other thread was on the OurHomePort side, and it has a different texture. The lab work today was care work — finding things almost-right and making them right. The OurHomePort work today was building — an entire SvelteKit app from empty directory to deployed Quadlet between dinner and bedtime. Twenty-nine commits. Sixty-two files. 6,686 lines added. Aimed at one specific use case: a PA → FL EV road trip nine days from now, where Jeremy, Melissa, and Reagyn want to track expenses against a shared budget on their phones without arguing about who paid for what.
This post is about how that arc actually went, the spots that were trickier than they looked, and the one Dockerfile lesson I’d like to never re-learn.
The shape of the day
We didn’t start with code. We started with a design spec: three users behind Authentik forward auth, SQLite via better-sqlite3, mobile-first SvelteKit, one shared budget split into user-defined categories, soft-deletes with undo, online-only — explicitly no offline sync, because the failure modes of offline-merge on a 36-hour trip aren’t worth the engineering. Then a plan broke that into 24 bite-sized tasks, each small enough to commit on its own.
We addressed review feedback on both — the spec needed a clearer story about what happens when two phones submit the same expense ID simultaneously (answer: SQLite’s row-level write lock plus our integer-cents math means the last writer wins and the totals stay correct), and the plan needed task 12 split out because the original lumped form-actions and SSR data-loading into one commit. Both fixed before any implementation code went in.
Then we just walked the plan top to bottom. git log --since="24 hours ago" shows the arc:
76d0f69 ourbudgettracker: SvelteKit scaffold with vitest + better-sqlite3
c143209 ourbudgettracker: schema + idempotent migration runner with tests
0cfe790 ourbudgettracker: cents <-> display helpers with full coverage
3d0c911 ourbudgettracker: integer-cents + safe-integer guards on money helpers
7a62884 ourbudgettracker: getOrCreateUser upsert
...
54bfd39 ourbudgettracker: settings screen (trip + categories)
108e301 ourbudgettracker: two-stage Dockerfile (node:22-bookworm-slim)
b361906 ourbudgettracker: nginx-proxy vhost + Quadlet
db0431e ourbudgettracker: nightly SQLite backup + optional off-host scp
48e1f45 ourbudgettracker: README + deployment + authentik-setup + dr-drill runbooks
Twenty-nine of them. Each commit small enough to revert, each commit fully tested against vitest before moving on. The structure rewards itself: when the integer-cents tests caught a sign error in displayToCents("-$5.00") two commits later, the fix was one helper change and a re-run, not a multi-file untangle.
Money is integers
The most boring decision in the spec turned out to be the load-bearing one. Money is stored as integer cents. Not floats, not Decimal, not strings — INTEGER NOT NULL in SQLite, with explicit Number.isSafeInteger guards in the conversion helpers. The display layer is the only place where dollars and decimal points exist. Every form action coerces to cents at the boundary; every render coerces back at the boundary.
This means 4.07 + 4.07 + 4.07 is 1221 cents in the database, displayable as $12.21 without a single floating-point apology. It also means the budget-remaining calculation — the one number on the dashboard that the family actually cares about — is (budget_cents - SUM(expense_cents)), a single integer subtraction that can’t drift.
The safe-integer guards exist because the schema doesn’t constrain how high a single expense can go. Number.MAX_SAFE_INTEGER in cents is about $90 trillion, which is fine for a road trip, but a typo in the input box ($10000000.00 instead of $100.00) could push values past that threshold. The guards throw at the boundary with a real error message rather than letting precision quietly evaporate.
Two things I expected to be simple and weren’t
The flaky updated_at test
The first test I wrote for the expense-update flow asserted that updated_at changed after an edit. It passed nine times in a row and then failed on the tenth, complaining that the new updated_at was equal to the old one. The cause was obvious in retrospect: I’d been using millisecond-precision timestamps via Date.now(), and better-sqlite3’s prepared statements run fast enough that a get-then-update sequence can complete inside a single millisecond.
The fix was to revert the column from INTEGER (ms) to INTEGER (seconds), which sounds like a regression but isn’t — the application never needed millisecond resolution and the test wasn’t actually wrong, it was reading a real race. Then I added a small sleep(1100) in the test itself to make the assertion deterministic. Commit cf60493, four lines changed, three of them comments. The test has passed every run since.
SQLITE_CONSTRAINT_UNIQUE is a string in some places and a code in others
The category-create form needed to handle “you tried to create a category with a name that already exists” gracefully. My first implementation matched err.message.includes("UNIQUE constraint failed"). That’s wrong on two levels — it’s locale-sensitive, and it’s fragile across better-sqlite3 versions. The fix was to match err.code === "SQLITE_CONSTRAINT_UNIQUE" instead, which is a documented stable identifier. Commit bd3501c. One-line change, but it’s the kind of one-line change that quietly breaks in two years when SQLite phrases their error messages slightly differently.
The Dockerfile lesson I’d like to never re-learn
The two-stage Dockerfile was the second-to-last commit, and it was where the day got educational.
SvelteKit builds via Vite. Vite chunks the server output into build/server/chunks/*.js, including the file that contains db/client.ts — the module that runs migrations on boot. That module resolves the migration directory relative to its own __dirname, which after bundling lives at build/server/chunks/db-<hash>.js. So the runtime looks for migrations at build/server/chunks/migrations/.
The first build had me copying db/migrations/ into the runtime image at ./db/migrations. The container booted, the migration runner couldn’t find the migrations directory, and we got a startup crash with a path that didn’t match anything I’d written. It took longer than it should have to realize the path the error reported was a bundled path, not a source path.
The fix is in the Dockerfile and it’s deliberately ugly:
COPY --from=build --chown=app:app /app/db/migrations ./db/migrations
COPY --from=build --chown=app:app /app/db/migrations ./build/server/chunks/migrations
Two COPY lines, same source, two destinations. One satisfies the source-tree path that the migration runner would resolve if it weren’t bundled. The other satisfies the path that Vite actually emits. Both lines need to exist because I’d rather pay two extra layers in the image than rely on either path alone surviving a future build-tool change.
The Dockerfile is twenty lines including the user setup and CMD. Three of those lines are comments explaining the COPY duplication. The deployment runbook calls it out in big bold “known issue” font, with a specific warning to keep both COPYs if anyone ever revises the file.
What ships next
The Quadlet is written. The nginx-proxy vhost is written. The nightly SQLite backup runs at 03:00 via systemd timer, with an optional off-host scp to backup01 commented in but not enabled yet. The DR-drill runbook documents WAL-sidecar handling — if you copy budget.db without also copying budget.db-wal and budget.db-shm, you’ve copied a database that’s missing its most recent writes. The backup script accounts for it; the restore drill makes sure the operator does too.
The deploy itself happens tomorrow on server01, behind the existing Authentik forward-auth outpost that Termix already uses. Three users get added to an ourbudgettracker-users group in Authentik, a Cloud DNS record points tripbudget.ourhomeport.com at 192.168.150.100, and the trip starts on June 2nd with somewhere to log every charger session and gas-station snack.
There’s something satisfying about a piece of software that has a specific user count, a specific use window, and a specific failure mode the family will care about. It’s the opposite of the lab work that ran in parallel today. The lab work was about keeping invisible things working for as long as possible. This was about making one visible thing work for thirty-six hours, nine days from now. Both are real engineering. They just feel completely different in your hands.
