Fresh DB Setup — End-to-End Walkthrough
Last Updated: 2026-07-11 Verified Against: Glad-Labs/poindexter Phase G squash (0000_baseline.py — true baseline-only, no post-baseline migrations) Verifier: dispatched code-writing agent (#378), refreshed 2026-05-23; Phase F counts updated 2026-06-22 This doc walks through standing up Poindexter against a fresh, empty Postgres database. It’s the canonical reference for:- A new operator install (laptop or VPS) — public Poindexter user.
- A test/staging environment that should mirror production.
- A disaster-recovery rebuild from
pg_dump+ bootstrap.toml. - The acceptance test for any change touching
services/migrations/,services/database_service.py,poindexter/cli/setup.py, orutils/startup_manager.py.
Pre-flight
You need:- Docker (Desktop or daemon) — Postgres + pgvector run in a
container. The migration runner targets
pgvector/pgvector:pg16in CI;pg17works locally. The container image is the only OS-level dep — Postgres itself is bundled. - Python ≥ 3.12 with
pip. The setup wizard installs the rest viapoetry. - About 5 minutes for the full chain (90 % of the time is the Postgres pull on a cold cache).
tailscalefor Grafana access from your phone (use your own tailnet IP, e.g.<your-tailnet-ip>:3000). Public Poindexter ships Grafana onlocalhost:3000; tailnet is operator-overlay only.ghCLI authenticated toGlad-Labs/poindexterif you want to file bug reports inline.
Step 1 — Spin a fresh Postgres container
Don’t run this against a populated DB. The migration runner is
idempotent at the row level (each migration records itself in
schema_migrations after success) but a half-applied migration on
a populated DB is dangerous to re-run blind.
Verify:
Step 2 — Apply migrations
The CI smoke script is the right tool here — it’s what production relies on, it asserts row-count equality, and it’s dependency-light.0000_baseline.py (plus 0000_baseline.schema.sql +
0000_baseline.seeds.sql) — re-rolled most recently by the Phase G
squash (2026-07-11), which folded the Phase F baseline + its 42
post-baseline migrations into a fresh baseline — true baseline-only, so the
migration tree is now a single file (0000_baseline.py alone; zero
post-baseline migrations, no surviving convergence step). Derive the expected
count dynamically — the -1 subtracts __init__.py:
- Every migration file applies without raising.
- Every migration records exactly one
schema_migrationsrow. - No orphan rows in
schema_migrations(rows without a matching file). - No row count mismatch (would catch a silently-skipped migration).
After step 2 the database should look like:
app_settings_seeded = 693 = the full 691-key is_secret = false
default set plus 2 empty-valued secret placeholders
(cloudflare_analytics_api_token, mcp_http_probe_recovery_token) — those
keys are seeded so the operator can fill them, but no secret value ever
ships. Since the Phase F/G baselines are generated fold-forward from real
DB state, 0000_baseline.seeds.sql ships every non-secret default (no
longer just a minimal migration-seeded subset). Other secret keys and
per-operator identity are NOT seeded; poindexter setup writes those.
settings_defaults.py still runs seed_all_defaults() at every boot as
an idempotent backstop, but on a fresh install the seeds file has
already populated the non-secret defaults.
oauth_clients_seeded = 0 is correct — the initial CLI client gets
provisioned by poindexter setup per-installation (each install
gets unique credentials), not by a shared migration.
Step 3 — Run the setup wizard
- DB connection — opens a connection, fetches
version(). - Migrations check — verifies
app_settingstable exists (proxy for “migrations already ran”). Will succeed because step 2 above ran them. - Write
bootstrap.toml— to~/.poindexter/bootstrap.toml. - Provision initial OAuth client — generates a CLI client_id +
client_secret pair, registers it in
oauth_clients, and stores the credentials inapp_settings.cli_oauth_client_id+app_settings.cli_oauth_client_secret(the secret is encrypted viaplugins.secrets.set_secret).
Step 4 — Run poindexter setup --check
--check is the no-op verification path — it touches no files,
provisions nothing, just reports per-component status.
Expected on a fresh-but-not-booted system:
FAIL for the brain daemon is expected pre-boot — the brain
hasn’t run yet so it’s never recorded a decision. After step 5 it
flips to OK.
Step 5 — Boot the worker against the fresh DB
This is the integration-test step. With the fresh DB, the worker’s lifespan should:- Connect to Postgres.
- Run migrations (no-op — already applied).
- Set up Redis cache (if
REDIS_URLis set; falls back to no-op). - Initialize the rest of the services.
- Log
Application started successfully!.
http://localhost:8002/health from another terminal:
200 OK with a JSON body containing "status": "ok" (or
similar — the /health handler is defined in main.py).
Then re-run the check to confirm everything is wired:
brain daemon will still FAIL (the brain is a separate process —
start it with python -m brain.daemon or your supervisor of choice).
worker API should now OK — the worker registered its own
api_base_url setting via the StartupManager.
Step 6 — Tear down
Summary — what gets verified
If all pass on a clean Docker host, the system is shippable.
Follow-ups and known gaps
These were discovered during the #378 fresh-DB verification pass and filed as separate issues — they don’t block this PR but improve the fresh-DB experience.-
LazyResolved (Phase F squash, 2026-06-22). The baseline (app_settingsseeding.0000_baseline.seeds.sql) now seeds all 691 non-secretapp_settingskeys generated fold-forward from real DB state, so a fresh install immediately has the full non-secret default set after step 2 — no lazy-load gap.services/settings_defaults.py::seed_all_defaults()(run byStartupManager._run_migrations()at every boot) remains as an idempotent backstop for keys added after the most recent squash. -
--autousespgvector/pgvector:pg16while CI uses the same image. Locally Matt’sdocker-compose.local.ymlrunspg17. Worth consolidating on one Postgres major across CI + local + auto-setup. -
_run_migrationsincli/setup.pyis a proxy check, not a real apply. It only verifiesapp_settingsexists; it doesn’t run the pending migrations. Step 2 above runs the smoke script as the real apply path. The setup wizard should ideally drive the runner directly sopoindexter setupon a fresh DB Just Works without the separatemigrations_smoke.pyinvocation.