Skip to main content

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, or utils/startup_manager.py.
If any step in this doc fails, the system is not in a shippable state. File a hard issue on Glad-Labs/poindexter with the failing output.

Pre-flight

You need:
  • Docker (Desktop or daemon) — Postgres + pgvector run in a container. The migration runner targets pgvector/pgvector:pg16 in CI; pg17 works 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 via poetry.
  • About 5 minutes for the full chain (90 % of the time is the Postgres pull on a cold cache).
Optional but recommended:
  • tailscale for Grafana access from your phone (use your own tailnet IP, e.g. <your-tailnet-ip>:3000). Public Poindexter ships Grafana on localhost:3000; tailnet is operator-overlay only.
  • gh CLI authenticated to Glad-Labs/poindexter if you want to file bug reports inline.

Step 1 — Spin a fresh Postgres container

The container exposes Postgres on host port 5433 — that’s the standard local-dev port for Poindexter (not 5432, to avoid clashing with a system Postgres). Pick any free port if you already use 5433 for the live DB.
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.
Expected tail output:
The migration history is squashed into a single 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:
Then verify against the DB:
The two numbers should match. What this verifies:
  • Every migration file applies without raising.
  • Every migration records exactly one schema_migrations row.
  • No orphan rows in schema_migrations (rows without a matching file).
  • No row count mismatch (would catch a silently-skipped migration).
Common failure modes:
SymptomLikely causeFix
MISSING INTERFACE: <file> from the lint stepMigration lacks up() AND run_migration()Add one — see migrations.md.
INVALID TIMESTAMP: <file>Timestamp prefix isn’t a real datetimeRegenerate with python scripts/new-migration.py "<slug>".
Migration N raises, N+1 succeedsRunner does NOT halt on per-migration failureInspect the logged exception. The runner returned False; the smoke test exits non-zero.
applied N — already up-to-date 0 — failed MSame as above; M migrations failed.Read the logs above the summary. Each failure has an exc_info=True trace.
extension "vector" does not existWrong Postgres image (vanilla postgres:16)Use pgvector/pgvector:pg16 or pg17. Migration 0000_base_schema.py requires it.
After step 2 the database should look like:
Expected (verified 2026-07-11 against the Phase G baseline):
Note: 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

The wizard runs four steps:
  1. DB connection — opens a connection, fetches version().
  2. Migrations check — verifies app_settings table exists (proxy for “migrations already ran”). Will succeed because step 2 above ran them.
  3. Write bootstrap.toml — to ~/.poindexter/bootstrap.toml.
  4. Provision initial OAuth client — generates a CLI client_id + client_secret pair, registers it in oauth_clients, and stores the credentials in app_settings.cli_oauth_client_id + app_settings.cli_oauth_client_secret (the secret is encrypted via plugins.secrets.set_secret).
Expected tail output:
Verify:
Common failure modes:
SymptomCause / fix
bootstrap.toml already existsExisting 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:
The 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:
  1. Connect to Postgres.
  2. Run migrations (no-op — already applied).
  3. Set up Redis cache (if REDIS_URL is set; falls back to no-op).
  4. Initialize the rest of the services.
  5. Log Application started successfully!.
Expected (truncated for legibility):
Hit http://localhost:8002/health from another terminal:
Should return 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

For a real install you obviously keep the bootstrap.toml + container.

Summary — what gets verified

ComponentStepVerification
pgvector extension available1Container image
Database accepts connections1pg_isready
Migration runner applies cleanly2schema_migrations row count matches file count
No orphan / missing rows2migrations_smoke.py row-set diff
Filename convention2migrations_lint.py (CI)
Bootstrap file write3~/.poindexter/bootstrap.toml exists, mode 600
Initial OAuth client provisioned3oauth_clients has 1 row, cli_oauth_* settings
--check passes4Per-component status
Worker lifespan succeeds5Application started successfully! in logs
Health endpoint responds5GET /health returns 200
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.
  • Lazy app_settings seeding. Resolved (Phase F squash, 2026-06-22). The baseline (0000_baseline.seeds.sql) now seeds all 691 non-secret app_settings keys 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 by StartupManager._run_migrations() at every boot) remains as an idempotent backstop for keys added after the most recent squash.
  • --auto uses pgvector/pgvector:pg16 while CI uses the same image. Locally Matt’s docker-compose.local.yml runs pg17. Worth consolidating on one Postgres major across CI + local + auto-setup.
  • _run_migrations in cli/setup.py is a proxy check, not a real apply. It only verifies app_settings exists; 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 so poindexter setup on a fresh DB Just Works without the separate migrations_smoke.py invocation.
These are tracked in the PR body as “follow-up issues to file.”