Never build new things inside the assistant
I built a small water-tracker for myself: pick a bottle preset,
tap a button, get a daily total. First version landed as a few
/water/* routes inside the main assistant backend's main.py.
That lasted about an hour before I tore it out.
What was happening
It felt natural at the time. The assistant already had auth,
DynamoDB, and a deploy pipeline. Adding /water/log and
/water/summary was four functions and a template. Done in an
evening.
The problem showed up immediately: any restart of the assistant —
for an unrelated chat-side change — restarted the water tracker.
Any code lock the assistant had (the file is chattr +i'd to
defend against an unrelated sync bug) was a lock the water
tracker inherited. The blast radius of "I want to ship a one-line
fix to the water UI" was every other surface the assistant served.
Worse, the assistant's main process was now responsible for an unrelated app's correctness. A bug in the water module could crash the chat surface. That's the wrong coupling.
What I found
The right architecture for personal projects on a homelab is boringly conventional: each app gets its own subdomain, its own systemd unit, its own port, its own nginx vhost. They share infrastructure (DynamoDB, the SSO provider, the certbot setup), not the same process.
The fix
Re-platformed in one evening:
- New systemd unit on a separate port, running its own uvicorn process. Reused the assistant's Python venv to avoid a separate install.
- New nginx vhost on the reverse proxy LXC, with its own Let's Encrypt cert via DNS-01.
- New subdomain CNAME pointing at the homelab.
- SSO via federation to the assistant's
/sso/authorize(already built for exactly this case — see a tiny SSO provider for personal subdomains). The water app issues its own session cookie after verifying the SSO token.
DynamoDB table is shared, but with a pk=WATER#… namespace, so
there's no collision with any other surface.
The systemd unit is the boring part:
[Unit]
Description=Water tracker
After=network-online.target
[Service]
Type=simple
User=chester
WorkingDirectory=/opt/water
EnvironmentFile=/opt/water/water.env
ExecStart=/opt/assistant/venv/bin/uvicorn main:app \
--host 0.0.0.0 --port 8090
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
Restart now only restarts the water app. The assistant has no opinion about it. The blast radius of a water-side bug is the water app.
What I'd do differently
This is now a standing rule for me: every new personal project gets its own subdomain + systemd unit + nginx vhost on day one, even if it's a five-route app. The marginal cost of standing up a new service is small once you've done it a few times. The cost of unwinding "I'll just add it to $existing_app" later is embarrassingly high — I'd already done the same thing once before with another small tool, and apparently needed to do it again to learn.
There's a corollary for things bigger than just systemd. If a new feature has its own users, its own state, or its own failure modes, it should have its own runtime boundary. Conway's Law also applies to "me, alone, at home" — the units of independent change want to be the units of independent deploy.