Skip to content

feat(ops): migrate Postgres to Neon.tech — verified end-to-end (#50) - #56

Merged
franciszver merged 9 commits into
mainfrom
feat/p-ops3-neon-migration-prep
Jul 24, 2026
Merged

feat(ops): migrate Postgres to Neon.tech — verified end-to-end (#50)#56
franciszver merged 9 commits into
mainfrom
feat/p-ops3-neon-migration-prep

Conversation

@franciszver

Copy link
Copy Markdown
Owner

Prep for #50DRAFT, do NOT merge until the owner verifies against a real Neon instance.

Status: PLAN ONLY, unverified against live Neon

This branch prepares the migration; nothing here has been run against a real Neon database. Merge only after the cutover succeeds (see runbook).

What's here

  • render.yaml — target state: databases: block removed, DATABASE_URL switched from fromDatabase: to a sync:false manual secret (Neon pooled string + ?sslmode=require), free-tier notes updated (Render 30-day expiry no longer applies; Neon persistent + autosuspend).
  • No app-code change neededget_database_url() already passes a Neon postgresql://...?sslmode=require DSN through verbatim; create_engine is lazy with pool_pre_ping=True. Confirmed by reading both.
  • tests/test_neon_dsn_handling.py — validates DSN handling only (Neon DSN preserved, postgres://postgresql:// normalization keeps query string, SQLAlchemy accepts the URL lazily). Does NOT verify live Neon connectivity (owner does, per runbook).
  • _docs/RUNBOOK-neon-migration.md — ordered cutover with a ⚠️ destructive-ordering warning (delete the Render DB LAST, only after Neon serves traffic).
  • .env.example — commented Neon DSN example. RUNBOOK-db-expiry-recovery.md — pointer noting Neon supersedes it.

⚠️ Known cutover blocker to resolve

scripts/setup_db.py::get_db_connection_string() builds its connection from DB_* parts and does NOT append sslmode — but Neon requires SSL. The migration/seed step will fail against Neon unless setup_db.py is taught to honor DATABASE_URL or append sslmode=require. Documented in the runbook; needs a decision (fix in this PR vs. handle at cutover).

Before eventual merge

Full three-gate review + owner's live-Neon verification. Suite currently: 528 passed, 1 skipped, 2 xfailed.

🤖 Generated with Claude Code

https://claude.ai/code/session_016v4C1daTwNakL6QDbmy3MJ

franciszver and others added 7 commits July 24, 2026 04:25
Verifies get_database_url() passes a Neon-style pooled DSN through
unchanged (sslmode + -pooler host preserved), normalizes bare
postgres:// scheme with query string intact, and that
sqlalchemy.create_engine() constructs (lazily, no connection) for the
resulting URL.

DSN handling only -- does NOT verify live Neon connectivity. That is
verified by the owner during cutover per _docs/RUNBOOK-neon-migration.md.

Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#50)

render.yaml: target end-state after migrating off Render Postgres to
Neon.tech. Removes the databases: block (elevareai-db) and changes
elevareai-api's DATABASE_URL from a fromDatabase: binding to sync: false
(manual secret, set to the Neon pooled connection string with
?sslmode=require in the Render dashboard). Updates the FREE TIER NOTES
comment: the Render-Postgres 30-day-expiry note no longer applies;
notes Neon's free tier is persistent with autosuspend/cold-start
absorbed by the existing pool_pre_ping=True.

No application code change needed: get_database_url() in
src/config/settings.py already passes DATABASE_URL through verbatim
(only normalizing postgres:// to postgresql://), and
src/config/database.py's create_engine() is lazy with pool_pre_ping=True
already set, so a Neon pooled DSN works unchanged.

Adds _docs/RUNBOOK-neon-migration.md: the ordered cutover plan,
emphasizing that removing the Render database (step 6) is destructive
and must only happen after Neon is confirmed serving traffic (steps
4-5). Documents the DB_* vs DATABASE_URL nuance in scripts/setup_db.py.
Marked as PLAN ONLY -- not yet executed against a live Neon instance.

Adds a pointer at the top of _docs/RUNBOOK-db-expiry-recovery.md (#34)
noting it no longer applies once migrated to Neon.

.env.example: adds a commented Neon DATABASE_URL example (format only,
no real value); existing DB_* local-dev defaults unchanged.

Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add test_psycopg2_params_from_url_carries_sslmode_and_channel_binding,
asserting that psycopg2 connect() params built from a Neon-style DSN
(?sslmode=require&channel_binding=require) include those query params.
Currently red: run_migration() builds conn_params directly from
url.host/port/database/user/password, dropping the DSN query string.

Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#50)

run_migration() previously built psycopg2 conn_params from
engine.url.host/port/database/user/password only, dropping the DSN
query string (sslmode, channel_binding). It worked against Neon only
because psycopg2 defaults to sslmode=prefer. Extract the param-building
into _psycopg2_params_from_url(), which merges url.query into the
conn_params dict so a Neon DSN's SSL requirements are honored
explicitly. Also note in create_database_if_not_exists() that it
carries no SSL/DSN params and should be skipped (--skip-create-db) for
managed DBs like Neon.

Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes (#50)

- RUNBOOK-neon-migration.md: banner now reflects that migration mechanics
  (connect via pooled SSL endpoint, migrate 18 tables via setup_db.py,
  seed demo data, demo@elevare.ai bcrypt login) were verified against a
  live Neon instance; remaining work is the Render-dashboard cutover.
  Fix step-3 over-claim: run_migration now explicitly carries the DSN's
  SSL params via _psycopg2_params_from_url(). Add a GOTCHA section on
  never adding non-schema keys (e.g. NEON_EXTERNAL_DATABASE_URL) to
  .env, since Settings uses extra="forbid". Note that cutover is a
  connection-string change, not a re-seed, since data already exists in
  the verified Neon DB.
- README.md / _docs/ARCHITECTURE.md: update the Render Blueprint
  description -- Postgres is now on Neon (persistent free tier), Render
  hosts only the web service + static site. Point to the Neon runbook.

Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@franciszver
franciszver marked this pull request as ready for review July 24, 2026 11:58
@franciszver franciszver changed the title [DRAFT — do not merge until verified against real Neon] Neon migration prep (#50) feat(ops): migrate Postgres to Neon.tech — verified end-to-end (#50) Jul 24, 2026
@franciszver

Copy link
Copy Markdown
Owner Author

VERIFIED against live Neon (2026-07-24): connect via pooled SSL endpoint, 18 tables migrated via setup_db.py, demo data seeded, demo@elevare.ai bcrypt login verified; the hardened _psycopg2_params_from_url connects to Neon with explicit sslmode=require+channel_binding=require. Production /health is connected and demo login returns 200 on Neon (owner set DATABASE_URL in the Render dashboard). ⚠️ Merging this removes the databases: block → blueprint sync deletes the old Render elevareai-db (safe: Neon confirmed serving). Owner approved merge.

)

Assisted-by: Claude Code (haiku subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@franciszver
franciszver merged commit 5d42dbe into main Jul 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant