Runbook: deploy scribe news-watch to Hetzner (ADR-008 Phase 4)
End-to-end deploy of the scribe-newswatch headless worker onto the
existing Hetzner VPS, writing to a shared Supabase backend. This is the
operational tail of ADR-008 — the architecture is built; this is the
sequence of ss commands + SSH steps to actually fire.
Read this before running. Several steps incur infra cost or overwrite production state. Steps marked ⚠ are destructive / costly.
Prerequisites
Section titled “Prerequisites”- The Hetzner machine at
5.161.83.131is up (verified viassh [email protected] systemctl status ss-agent) ssCLI is on PATH locally- Hetzner API token exists in the vault:
If empty:ss vault list-keys hetzner
ss vault set-key hetzner api_token=<from console.hetzner.cloud> - A Supabase project is provisioned and its credentials are in the
celestial vault (already there for decider-sweep):
Expected:ss vault list-secrets --project=celestial --env=prod | grep -E "SUPABASE|DATABASE"
SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SUPABASE_CONNECTION_STRING. - Verify the
SUPABASE_CONNECTION_STRINGactually works. It can go stale if you rotate the DB password in the Supabase dashboard without refreshing the vault. Quick check:If you seeSCRIBE_STORE=supabase SCRIBE_VAULT_PROJECT=celestial SCRIBE_VAULT_ENV=prod SCRIBE_WORKSPACE_ID=test \node -e "import('@celestial/scribe-memory').then(({resolveMemoryStore}) => resolveMemoryStore().then(r => r.store.listProjects()).then(p => console.log('OK', p.length)).catch(e => { console.error('FAIL:', e.message); process.exit(1) }))"FAIL: Tenant or user not found: open the Supabase dashboard → Project Settings → Database → “Connection string” tab → copy the Transaction pooler URI → update the vault:ss vault set-secret SUPABASE_CONNECTION_STRING='<paste-uri>' --project=celestial --env=prod
Step 1 — apply Postgres migrations to Supabase
Section titled “Step 1 — apply Postgres migrations to Supabase”The scribe schema (atoms, sessions, sources, feeds, candidates,
pipeline_runs, etc.) needs to exist before the SupabaseMemoryStore can
write to it. The migration runner pulls credentials from ss vault
itself — no env-var dance required:
node packages/scribe-memory/dist/cli.js migrate --store=supabase(--vault-project defaults to celestial, --vault-env to prod.)
Expected output:
[migrate] → 999_migration_tracker[migrate] → 001_memory_schema[migrate] → 002_rls_policies[migrate] done — 3 migration(s) checked⚠ The migrations enable RLS on every table. After this step, anonymous
queries to Supabase see zero rows. Use the service-role key (which
bypasses RLS) for admin work; the scribe-newswatch worker uses
workspace_id + the same key to scope to its tenant.
Step 2 — smoke test SupabaseMemoryStore locally
Section titled “Step 2 — smoke test SupabaseMemoryStore locally”Before deploying, verify the adapter can talk to Supabase from your laptop. Run news-feeds-poll-all (or any pipeline) against the new backend:
node packages/scribe-memory/dist/cli.js pipeline news-feeds-poll-all \ --store=supabase --workspace=jek-laptopExpected output line:
[scribe-memory] store: supabase (vault project=celestial env=prod) workspace=jek-laptopIf you see auth errors or “relation does not exist”, Step 1 didn’t complete; re-check.
Step 2.5 — bootstrap the host (one-time)
Section titled “Step 2.5 — bootstrap the host (one-time)”ss artifact deploy ships a pre-built Node bundle. The target host
needs Node 22 installed exactly once. If you’ve never deployed to a
host before:
ssh -i ~/.ssh/<your-hetzner-key> root@<host> \ 'curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y nodejs && node --version'This will eventually move into a proper ss host bootstrap <service>
command — for now it’s a one-line copy-paste.
The artifact bundle itself does NOT include a Node binary. We debated bundling node-pre-gyp style but Phase 1 of ADR-011 keeps the artifact pure-JS for portability across host distros; the host runs
apt install nodejsonce.
Step 3 — deploy the artifact
Section titled “Step 3 — deploy the artifact”ADR-011 Phase 1: build a self-contained tarball locally, scp it, flip a symlink. The Hetzner box never sees git/pnpm/repo source.
# Inspect what would change (build only; no upload)ss artifact build scribe-newswatch --env=prod# ⚠ Build + upload + activatess artifact deploy scribe-newswatch --env=prod \ [--key=~/.ssh/<your-hetzner-key>]What this does:
- Compiles
@celestial/scribe+@celestial/scribe-memory. - Vendors them under
node_modules/@celestial/in a staging dir along with their pruned external production deps (~43 packages, ~5 MB). - Tars to
.starsystem/artifacts/scribe-newswatch-<commit>-prod.tar.gz. scps to/tmp/on the target.- Unpacks to
/opt/scribe-newswatch/<commit>/. - Materializes
/etc/scribe-newswatch/envfrom_systemd.env+_systemd.env_from_vault(reads OLLAMA_* fromss vault). - Renders + drops
/etc/systemd/system/scribe-newswatch.service(no-op if unchanged; daemon-reload only when changed). - Atomically flips
/opt/scribe-newswatch/current → <commit>. systemctl enable --now scribe-newswatch+ restart.- Prunes old revs beyond
_artifact.keep_revs(default 5).
Rollback: SSH to the host and re-point
currentat any prior commit dir still on disk:ln -sfn /opt/scribe-newswatch/<old-sha> /opt/scribe-newswatch/current && systemctl restart scribe-newswatch. Phase 2 will turn this intoss artifact rollback.
Step 4 — verify the worker is running
Section titled “Step 4 — verify the worker is running”# Check systemd statusss exec --env=prod --service=ss-agent -- systemctl status scribe-newswatch
# Tail the journalss logs --env=prod --service=scribe-newswatch --follow
# Confirm process-manager / ss-agent sees itss status --env=prodExpected:
- systemd:
active (running) - journal:
[scribe news-watch] starting (pid …)+news-watch poll loop: every 30m[scribe-memory] store: supabase (vault project=celestial env=prod) workspace=hetzner-newswatch
Step 5 — verify cross-machine state via the laptop UI
Section titled “Step 5 — verify cross-machine state via the laptop UI”On your laptop, point scribe-serve at the same Supabase and confirm news the Hetzner worker ingests appears in your UI:
SCRIBE_STORE=supabase \SCRIBE_VAULT_PROJECT=celestial SCRIBE_VAULT_ENV=prod \SCRIBE_WORKSPACE_ID=hetzner-newswatch \ node packages/scribe/dist/cli.js serve(Use the same SCRIBE_WORKSPACE_ID as the worker so RLS gives you the
same scope.)
Open http://localhost:3742 → News tab. Candidates the Hetzner worker discovered should appear within a poll cycle.
Rollback
Section titled “Rollback”If anything is wrong:
ss exec --env=prod --service=ss-agent -- systemctl stop scribe-newswatchss exec --env=prod --service=ss-agent -- systemctl disable scribe-newswatchThe Supabase data persists (it’s the durable layer). To wipe and start
over, the migration runner’s _scribe_migrations table tracks applied
versions — manual cleanup is required to re-apply migrations from scratch.
Open follow-ups (post-deploy)
Section titled “Open follow-ups (post-deploy)”- ADR-009 (cold-layer sync) — the outbox + scribe-syncer process that gives you the “local-first with cloud mirror” experience instead of “everything writes directly to Supabase”. Right now, when the Hetzner worker loses network, ingestion just halts. ADR-009 adds the local outbox that drains when the network returns.
- ADR-010 (warm-layer SpacetimeDB fanout) — sub-second cross-agent atom visibility. Optional; needed when there’s actually a second agent in the workspace.
- Move scribe-newswatch to Railway — once ADR-009 ships, the persistent-disk argument that pinned us to Hetzner disappears. Railway gives auto-deploy-from-git and shared quota with starflow-server. See ADR-008 §“Operational trajectory note”.