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).
| Symptom | Likely cause | Fix |
|---|---|---|
MISSING INTERFACE: <file> from the lint step | Migration lacks up() AND run_migration() | Add one — see migrations.md. |
INVALID TIMESTAMP: <file> | Timestamp prefix isn’t a real datetime | Regenerate with python scripts/new-migration.py "<slug>". |
| Migration N raises, N+1 succeeds | Runner does NOT halt on per-migration failure | Inspect the logged exception. The runner returned False; the smoke test exits non-zero. |
applied N — already up-to-date 0 — failed M | Same as above; M migrations failed. | Read the logs above the summary. Each failure has an exc_info=True trace. |
extension "vector" does not exist | Wrong Postgres image (vanilla postgres:16) | Use pgvector/pgvector:pg16 or pg17. Migration 0000_base_schema.py requires it. |
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).
| Symptom | Cause / fix |
|---|---|
bootstrap.toml already exists | Existing install — back it up and re-run with --force, or run --check to verify the existing one. |
OAuth provisioning step prints Could not provision OAuth client: … | mcp.shared.auth import failed. Ensure pip install mcp has run (it’s a dep of pyproject.toml). bootstrap.toml is still saved — you can run poindexter auth migrate-cli after the worker boots. |
Connection failed: … | DB URL wrong, or container not yet ready. Re-check step 1. |
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
| Component | Step | Verification |
|---|---|---|
| pgvector extension available | 1 | Container image |
| Database accepts connections | 1 | pg_isready |
| Migration runner applies cleanly | 2 | schema_migrations row count matches file count |
| No orphan / missing rows | 2 | migrations_smoke.py row-set diff |
| Filename convention | 2 | migrations_lint.py (CI) |
| Bootstrap file write | 3 | ~/.poindexter/bootstrap.toml exists, mode 600 |
| Initial OAuth client provisioned | 3 | oauth_clients has 1 row, cli_oauth_* settings |
--check passes | 4 | Per-component status |
| Worker lifespan succeeds | 5 | Application started successfully! in logs |
| Health endpoint responds | 5 | GET /health returns 200 |
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.