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 #
- 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).
- Tailscale on the box:
tailscale up --ssh. Verifyssh <user>@dbugs-appworks over the tailnet before closing public SSH. - Lock down SSH: DO Cloud Firewall inbound = 443 only (drop 22); then
systemctl disable --now ssh(orapt purge openssh-server). Manage via Tailscale SSH from here on. - DNS:
Arecorddbugs.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). - Box setup:
ssh <user>@dbugs-app 'bash -s' < deploy/setup.sh(service userdbugs,/var/lib/dbugs, unattended-upgrades). - (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, sodbugs.db-walmay hold rows not yet indbugs.db; copying only.dbwould silently drop them. With the local server stopped:
Otherwise it starts with an empty issue list (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'-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 FTSrebuildonly 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.
- 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.
- Authorization callback URL:
- Client ID (not secret) goes in the unit:
Environment=GITHUB_CLIENT_ID=…(already set). - 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 launchdbugswithout 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_secretmust match the unit'sLoadCredentialEncrypted=id; it's exposed at$CREDENTIALS_DIRECTORY/github_client_secret(0400, service user only, encrypted at rest). EnvGITHUB_CLIENT_SECRETis the local-dev fallback. Rotate the secret if it's ever been exposed; re-encrypt after any box rebuild (host-bound key changes). - Verify after deploying (or after rotating, add
sudo systemctl restart dbugsfirst):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/cacheare localhost-only (reach viassh <user>@dbugs-appthencurl 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 0for ~0.37 MB if you need more).-compress(default on) → brotli for the SSE stream, all browsers.-compress=falseserves 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.