Cat2Jam
Role: sole engineer · Status: on hiatus since Sep 2026 — built, deployed and in daily use before that · Stack: Node.js, Hono, SQLite, Tauri 2 / WebView2, Tailscale
A private music client for a small group of friends. The desktop client is a fork of an open-source Tauri app; everything described below is the backend I designed, built and operate — accounts, library sync, listening history, profiles, invites and follows.
This writeup is about the service and its operations. The client fork is not the interesting part.
The design decision that shaped everything
The obvious way to build this is to put the server in the middle of everything. I didn’t.
┌────────────────────────────────┐
audio ───────┤ upstream hosted media servers │ (bulk data — never touches my server)
(bulk) └────────────────────────────────┘
▲
│
┌──────┴───────┐
│ desktop app │
│ (Tauri 2 / │
│ WebView2) │
└──────┬───────┘
│ control plane only
│ (auth, sync, scrobbles, social)
▼
┌───────────────────────────────────────────────────────────┐
│ Tailscale Funnel ──► 127.0.0.1:8787 (loopback only) │
│ │ │
│ └──► Hono (Node.js) ──► SQLite (WAL) │
│ │ │
│ └──► backups, 6-hourly │
└───────────────────────────────────────────────────────────┘
Audio never transits my server. Media streams go from the upstream hosts straight to each client. My backend carries only the control plane: identity, library sync state, scrobbles, profiles, invites, follows — kilobytes per user per day.
Separating the bulk data path from the control plane meant a single machine on a residential connection could serve the whole group without ever becoming the bottleneck. Bandwidth, storage and cost all scale with metadata, not with music. It also keeps the blast radius small: the service holds listening history, not media.
This is the same control-plane/data-plane split that shows up in SDN and in CDN design, and it was the right call for the same reason — put the small, stateful, latency-tolerant traffic through the thing you operate, and keep the large flows off it.
Exposing it: why Tailscale Funnel instead of a port forward
The default answer is: forward 443 at the router, run a reverse proxy, get a Let’s Encrypt certificate, renew it forever. I chose a WireGuard-based overlay instead.
| Port forward + LE | Tailscale Funnel | |
|---|---|---|
| Inbound firewall holes | Yes — 443 open to the internet | None |
| Origin reachable directly | Yes, once the port is known | No — binds 127.0.0.1 only |
| TLS certificate lifecycle | Mine to manage and renew | Handled by the overlay |
| Exposure if the app has a bug | Whole internet | Still whole internet, but one narrow ingress I can revoke instantly |
| Cost | Domain + setup | Free at this scale |
The property I actually wanted is the second row: the origin process never binds to a public interface. There is no listening socket for the internet to find. Everything arrives through one ingress I can turn off with a single command. For a service holding real people’s data, run by one person with no on-call rotation, reducing the attack surface mattered more than owning the TLS stack.
The trade-off is honest: I’ve taken a dependency on a third party for ingress, and if it goes
down, so do I. docs/DEPLOYING.md in the repo carries a Cloudflare Tunnel fallback for the day
a real domain gets bought.
Operational design
Things I built because there is no staging environment and no second operator:
- A preflight check that gates public exposure.
npm run check -- --publicvalidates the configuration and exits non-zero on a real problem. It’s run before any change to how the service is exposed. It reports two expected warnings, and I kept them as warnings rather than suppressing them, so the check stays meaningful. - Backups every six hours and at boot, keeping 28 generations, written outside the repo tree. The database is production and development simultaneously — it holds real listening history and there is no other copy.
- Autostart as a scheduled task on logon. Documented deliberately as logon, not boot — an unattended reboot does not bring the service back. That’s a real limitation, and writing it down honestly was more useful than pretending otherwise.
- Invite-only registration. No open signup surface at all.
What broke, and what it taught me
A running desktop shell will happily execute stale code, and the dev server will lie to you about it.
The auth module creates its manager object at import time — top-level side effects. Vite’s hot module replacement cannot swap that: it reloads the module, the old instance keeps running, and the UI keeps rendering state from code that no longer exists on disk. I “fixed” a bug, watched HMR report success, and the user was still looking at the old behaviour.
Two things came out of that:
- After touching any module with import-time side effects, restart the shell. HMR success is not evidence.
- Verify against the running application, not the diff. I now attach a Chrome DevTools Protocol debugger to the live WebView2 instance and drive the real UI. Playback is gated by a bot check that headless browsers cannot pass, so the real webview is the only surface where an end-to-end test means anything.
A related mistake worth recording: I once verified a UI change by asserting on the three element IDs I had patched. They all passed. I had missed several other links elsewhere on the page that pointed at the old target, because nothing was looking for them. Checking what you changed is not the same as checking what the user sees — enumerate the rendered page.
What I’d do differently
- Version control from day one. Meaningful work existed before the first commit. Nothing was lost, but nothing was recoverable either.
- Bind the service lifecycle to boot, not logon, or run it somewhere that doesn’t depend on a desktop session.
- Structured request logging earlier. Debugging a service you can’t reproduce locally is much harder without it.
The live endpoint and configuration are deliberately omitted — it’s a small private service holding real users’ data. Architecture and operational decisions are the transferable part.