Educational demo of a single node, sqlite, Datastar, CQRS app with low latency UX.
README.md

Deploying dbugs to a single DigitalOcean droplet #

Single box, no Docker. Pure-Go static binary (CGO_ENABLED=0) + go:embed'd templates/static = one file to ship. Management over Tailscale; only 443 public.

One-time #

  1. Droplet: Premium 4 GB / 2 vCPU (SFO3), Debian 12 or Ubuntu 24.04, SSH key (never password). Local disk for the DB (not a Block Storage volume).
  2. Tailscale on the box: tailscale up --ssh. Verify ssh <user>@dbugs-app works over the tailnet before closing public SSH.
  3. Lock down SSH: DO Cloud Firewall inbound = 443 only (drop 22); then systemctl disable --now ssh (or apt purge openssh-server). Manage via Tailscale SSH from here on.
  4. DNS: A record dbugs.yagni.club → droplet public IP (TTL 300; grey-cloud / DNS-only if on Cloudflare). Confirm: dig +short dbugs.yagni.club. Autocert can only issue once this resolves to the box (LE validates on :443).
  5. Box setup: ssh <user>@dbugs-app 'bash -s' < deploy/setup.sh (service user dbugs, /var/lib/dbugs, unattended-upgrades).
  6. (optional) demo data: copy a seeded DB up before first start. Checkpoint the WAL first — we run in WAL mode and kill/SIGTERM doesn't checkpoint, so dbugs.db-wal may hold rows not yet in dbugs.db; copying only .db would silently drop them. With the local server stopped:
    sqlite3 dbugs.db "PRAGMA wal_checkpoint(TRUNCATE);"   # fold WAL into the .db
    scp dbugs.db <user>@dbugs-app:/tmp/
    ssh <user>@dbugs-app 'sudo install -o dbugs -g dbugs /tmp/dbugs.db /var/lib/dbugs/dbugs.db'
    
    Otherwise it starts with an empty issue list (-seed 0). The schema, label catalog, indexes, and FTS are all created on first start either way; migrations are idempotent and re-run as no-ops on every redeploy (the one-time FTS rebuild only runs when the index is first absent).

GitHub auth (one-time) #

Reads are open; writes (comment/create/edit) require GitHub sign-in. Auth is off unless a client id + secret are present, so local dev needs nothing.

  1. Create a GitHub OAuth App (Settings → Developer settings → OAuth Apps).
    • Authorization callback URL: https://dbugs.yagni.club/auth/github/callback
    • Homepage URL: https://dbugs.yagni.club; no scopes needed.
    • If the client id looks like Ov23li… you made a GitHub App, not an OAuth App — it still works, but enable "user authorization (OAuth)" and set the callback URL in the App's settings.
  2. Client ID (not secret) goes in the unit: Environment=GITHUB_CLIENT_ID=… (already set).
  3. Store the client secret as an encrypted systemd credential (no .env). Run this on the box (over Tailscale SSH) — the ciphertext is host-bound, so it only decrypts where it was created. Do this before the first deploy.sh: the credential file must exist before the service starts (LoadCredentialEncrypted= is mandatory — systemd won't launch dbugs without it).
    sudo install -d -m 0700 /etc/dbugs
    # leading space keeps it out of shell history; printf '%s' = no trailing newline
     printf '%s' 'THE_CLIENT_SECRET' | sudo systemd-creds encrypt --name=github_client_secret - /etc/dbugs/github.cred
    
    --name=github_client_secret must match the unit's LoadCredentialEncrypted= id; it's exposed at $CREDENTIALS_DIRECTORY/github_client_secret (0400, service user only, encrypted at rest). Env GITHUB_CLIENT_SECRET is the local-dev fallback. Rotate the secret if it's ever been exposed; re-encrypt after any box rebuild (host-bound key changes).
  4. Verify after deploying (or after rotating, add sudo systemctl restart dbugs first):
    journalctl -u dbugs -n 20 --no-pager | grep -i 'auth:'   # want: auth: enabled=true
    

The cookie signing key auto-generates to /var/lib/dbugs/auth_key on first run — nothing to manage. Sign-in persists across restarts (stateless signed cookie). Fail-safe: if the secret is ever missing, the app falls back to browse-only (reads open, writes disabled) — it never silently allows anonymous writes.

After the secret is in, do a real sign-in at https://dbugs.yagni.club to confirm the full OAuth round-trip (login → callback → token exchange) works on the domain.

Every deploy #

From the repo root:

./deploy/deploy.sh dbugs-app          # build → scp over tailnet → atomic swap → restart

The DB persists across restarts; open SSE clients reconnect automatically (Datastar's built-in retry).

Knobs (in dbugs.service) #

  • -tls-domain dbugs.yagni.club → autocert HTTPS on 443 (TLS-ALPN-01, no port 80).
  • -admin-addr 127.0.0.1:8081 → /debug/gc + /debug/cache are localhost-only (reach via ssh <user>@dbugs-app then curl localhost:8081/debug/...). The in-app 📊 panel rides the main projection stream — no public debug endpoint.
  • -max-conns 3000 → 503 past the cap (sheds load instead of OOM).
  • -brq 2 -brwin 16 → ~0.7 MB/conn (drop to -brq 0 for ~0.37 MB if you need more).
  • -compress (default on) → brotli for the SSE stream, all browsers. -compress=false serves uncompressed to everyone — a debug/fallback kill-switch, not for normal use.
  • GOMEMLIMIT=3000MiB → soft heap cap; set to ~75% of the droplet RAM.

Resize for a bigger box by bumping -max-conns and GOMEMLIMIT together.

Safari / WebKit note #

WebKit buffers a streamed fetch response until more data arrives, which would otherwise leave Safari on a blank shell. Two things cover it, so Safari gets full brotli like every other browser: GET / server-renders the whole page (first paint never depends on the stream), and the stream sends a short keepalive burst to WebKit clients on connect to flush the buffer for live updates. Static assets are cache-busted via ?v=<build> so a deploy's CSS/JS lands immediately.

Observe #

  • In-app 📊 panel (streams / sessions / w/s / push/s / queue / cache % / brotli).
  • ssh <user>@dbugs-app 'curl -s localhost:8081/debug/gc' for clean heap stats.
  • ssh <user>@dbugs-app 'journalctl -u dbugs -f' for logs.

Traces (journald + jq, no collector) #

The unit sets OTEL_TRACES_EXPORTER=console: every span is one JSON line on stdout, so journald is the trace store — rotation and retention included, no collector or UI to run. Span taxonomy and trace-reading guide: the observability essay. Built for agent analysis over SSH: filter with jq -R 'fromjson?' (skips the plain log lines), e.g.

# everything the writer did in the last 10 minutes (batch sizes, queue waits)
journalctl -u dbugs --since '10 min ago' -o cat \
  | jq -Rc 'fromjson? | select(.Name == "writer.flush")
            | (.Attributes | map({(.Key): .Value.Value}) | add)'

# span durations in ms, slowest first (handles fractional RFC3339 timestamps)
journalctl -u dbugs --since '10 min ago' -o cat | jq -Rc '
  def t: capture("(?<base>.*T[0-9:]+)(?<frac>\\.[0-9]+)?(?<tz>[+-][0-9:]+|Z)") |
    ((.base + (.tz | sub(":";"")) | strptime("%Y-%m-%dT%H:%M:%S%z") | mktime)
     + ((.frac // "0") | tonumber));
  fromjson? | {name: .Name, ms: (((.EndTime|t) - (.StartTime|t)) * 1000)}' \
  | jq -sc 'sort_by(-.ms) | .[:20][]'

# stitch one slow request: grab its TraceID, then pull every span in the trace
journalctl -u dbugs --since '1 hour ago' -o cat \
  | jq -Rc 'fromjson? | select(.SpanContext.TraceID == "<id>")
            | {name: .Name, start: .StartTime, parent: .Parent.SpanID}'

writer.flush spans carry Links back to the writer.submit spans of every request coalesced into the batch (follow Links[].SpanContext.TraceID). Sampling is 100% while traffic is light; flip the commented OTEL_TRACES_SAMPLER lines in the unit if the volume ever matters. To graduate to a UI later, run Jaeger all-in-one on the box and swap the env var for OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 — no code change.