Article · Jul 7, 2026
How I migrated a live app off Lovable onto my own Supabase and Vercel: the full runbook
Step-by-step runbook to move a live app from Lovable to your own Supabase and Vercel, with rollback gates, real incidents, and fixes.
I was working on lovable built app with live users. Lovable held the database, hosting, and auth behind its console. That worked until I needed grown-up backend basics: true dev vs prod separation, safe schema testing on branches, and full ownership of my Postgres, functions, and keys. So I moved it to Supabase and Vercel, with the same domain and the live users.
This is the runbook exactly as I ran it: commands, dashboard clicks, scripts, and Lovable-specific traps, in order. Every step ends with a done when gate, and I only moved forward after it was closed. That discipline saved me more than once, from the merge that deleted my dev branch to the env-var typo that pointed dev at production, to functions that existed on no branch at all. One rule stayed constant: keep Lovable frozen as rollback until you are certain, and make sure every step has a way back.
How to read this. Read the prose straight through and you get the story and the postmortem. The commands and dashboard clicks are inline, so the same read-through also works as a runbook you can execute. Two kinds of grey drop-down go deeper without cluttering the path: Code holds the real, drop-in source files, and Deeper unpacks a design choice for anyone who wants the why. Green boxes define a term the first time it appears, so newcomers to Supabase or Vercel are never stranded. Skip whatever you already know.
Here is the whole journey at a glance, because the single most reassuring property of this plan is that users notice nothing until the very end:
| Step | What users see at your domain | What is running on the new stack |
|---|---|---|
| 1 to 5 | Lovable, unchanged | New infra built quietly |
| 6 and 7 | Lovable, unchanged | Real-data rehearsal on a Supabase dev branch and a Vercel preview |
| 8 (during) | Lovable, read-only | Fresh export and import into Supabase main |
| 8 (after DNS) | Vercel plus your new Supabase | Production |
| 9 | Vercel plus your new Supabase | Monitoring; Lovable still exists for rollback |
Deciding how far to go: backend-only vs full exit
There are two honest ways to leave a managed host, and I wrote both up before committing.
Backend only. Move the database, functions, and secrets to my own Supabase, but keep Lovable hosting the frontend, and cut over by repointing Lovable’s Supabase integration at the new project in its own UI. Fewest moving parts, one-click rollback. But I would still depend on Lovable for hosting, and worse, I would depend on its console cooperating at the exact moment of cutover, through a UI action with no API and no escape hatch.
Full exit. Move the backend to Supabase and the frontend to Vercel, and switch the production domain with DNS. More to build, but rollback becomes a single DNS record change that never asks Lovable for anything.
Why a DNS cutover beats a reconnect: with DNS, both systems exist in parallel and the domain just points at one of them, so rollback is editing one record back. A reconnect is a single point of no return inside someone else’s console.
I chose the full exit. If I was going to do the scary work at all, I wanted the rollback to be mine and instant, not borrowed. Either way, one rule applies to both paths: never delete the old Lovable project until you have been stable for about 30 days.
Step 1: Disconnect Lovable from GitHub
The first move touches nothing users can see. In the Lovable editor, Settings, GitHub, I clicked Disconnect. This severs only the auto-deploy pipeline; Lovable’s chat still works, and the live app keeps serving exactly as before. I confirmed that by loading the app, logging in, creating a record, and opening the credential vault. All normal. If anything had broken at this point, the right move is to stop, reconnect, and investigate, because a disconnect should be completely invisible to users.
Why first? Because from here on I would be pushing commits that must not auto-deploy to the live Lovable app. Once disconnected, commits on main can target the new Supabase project without Lovable racing me to deploy them.
I kept one hotfix path open: if a real bug hit the live app mid-migration, I can just solve it using Lovable chat.
Done when: Lovable is disconnected and the live app still serves normally.
Step 2: Create your own Supabase project and choose modern API keys
I created a new Supabase project on the Pro plan (Branching and add-ons need it), saved the database password in a password manager, noted the project ref (the <project-ref> part of https://<project-ref>.supabase.co), and linked locally:
brew install supabase/tap/supabase # if not already installed
supabase login
supabase link --project-ref <new-project-ref> # writes supabase/config.toml
Then in the dashboard, Project Settings, Integrations, GitHub, I set the production branch to main, so future pushes apply migrations automatically.
Here is the first decision that ripples through everything else. Brand-new Supabase projects issue modern API keys (sb_publishable_… and sb_secret_…); the legacy anon / service_role JWT keys are on their way out.
What these keys are, in plain terms: the publishable key is safe to ship in a browser or a browser extension; on its own it can only do what your access rules allow. The secret (or service-role) key is server-only and bypasses those rules, so it must never reach the frontend. A JWT is just the signed token that proves which user is logged in.
Edge function: a small server-side function that runs on Supabase’s own infrastructure, one per job (invite a user, send an email, verify a webhook). You reach for one whenever something needs a secret key, or logic you would not trust the browser with.
I took the modern keys so I would not have to migrate again later, and that choice has two code consequences I had to honour everywhere:
-
Every edge function that read the service-role key had to change how it reads it. Old way,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!. New way:JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)['default']Update every function before you deploy it.
-
Every function must deploy with
--no-verify-jwt. With modern keys, Supabase’s gateway tries to parse the opaque token as a JWT and returns401 "JWT is invalid"before your code ever runs. The functions still authenticate the caller internally (viasupabase.auth.getUser(jwt)); you are only turning off the automatic gateway check.
verify_jwtin one line: this is the Supabase API gateway pre-check that runs before your function code, not your function’s own auth logic.
The frontend side is simpler: the Vite app reads VITE_SUPABASE_PUBLISHABLE_KEY, and the value is the plain default string extracted from the small SUPABASE_PUBLISHABLE_KEYS JSON blob in Dashboard, API Keys. Paste the string, not the JSON wrapper. Hold that thought, because a wrong paste here becomes a real bug three steps from now.
Miss either of the two function-side changes and things fail in ways that look like auth bugs. Writing them down as a rule up front saved me from chasing ghosts later.
Done when: the project exists, supabase link works, GitHub is connected, and the key migration is planned in code.
Step 3: Rebuild the database schema with supabase db push
The schema moves almost for free, because it is already expressed as migration files in the repo.
Migrations and
db push: your database structure lives in the repo as a series of timestamped SQL files (migrations).supabase db pushreplays them in order against a database, rebuilding every table and rule from scratch. It is how the exact same schema can exist on Lovable, on your new project, and on any branch, from one source of truth.
But there is one Lovable-to-Supabase trap: a few extensions must be enabled by hand in the dashboard before you push, or the migration that references them fails with a permission error on a fresh project. So, in the dashboard first:
- pgmq: Integrations, Queues, Enable
- pg_cron: Database, Extensions
- pg_net: Database, Extensions
Then replay every migration:
supabase db push # recreates every table, RLS policy, trigger, function, index; zero rows
Row-Level Security (RLS): Postgres rules that decide, row by row, which logged-in user is allowed to read or change it. In Supabase this is your main line of defence, because the frontend talks to the database directly, so it is these rules, not your app code, that actually keep one user out of another’s data.
Done when: the schema exists on the new project with no data in it.
Step 4: Configure Supabase Auth, Postmark email, and the edge functions
Two things make auth the fussiest step: the Auth config lives in the dashboard (not in your repo, so version control cannot see it), and one of my edge functions was chained to Lovable’s own email libraries and would not run anywhere else.
4A. Auth configuration
In the dashboard, Authentication, I set only the values that differ from Supabase’s secure defaults and trusted the rest:
| Setting | Value |
|---|---|
| Site URL | https://your-app.example.com |
| Redirect URLs | your post-invite path, e.g. https://your-app.example.com/set-password, plus https://*.vercel.app/** temporarily for testing (removed in Step 9) |
| Allow new signups | OFF (the app creates users through an invite function that bypasses the toggle) |
| Confirm email | ON |
| Secure email change | ON |
| Email OTP expiry | 3600s, the default |
Two of those deserve a sentence each. The invite function passes a redirectTo, and Supabase rejects the whole invite call if that URL is not in the allowlist, so the redirect list is not optional. And the OTP expiry governs password recovery too, so a long invite window would also loosen recovery; leave it alone.
4B. Postmark, or whichever transactional provider you actually use
Incident log: Appendix C, incident 1.
Before touching secrets, audit your codebase for which provider is actually wired. I assumed I had both Resend and Postmark in play; only Postmark existed. Ten minutes of grepping saved me from configuring a provider the code never calls.
I reused an existing Postmark server and a sending domain I had already verified, so I skipped domain verification and set the secrets:
supabase secrets set CRON_SECRET=$(openssl rand -hex 32)
supabase secrets set POSTMARK_SERVER_TOKEN=<server-token>
supabase secrets set EMAIL_FROM="My App <notifications@your-verified-domain>"
One small thing that cost me a confused minute later: give EMAIL_FROM a display-name form. A bare address makes Postmark fall back to the sender signature’s default name, which may not be your app’s name.
While I am on cost decisions: Supabase offers a custom domain add-on at $10 a month that brands the data API URL, and I skipped it. It only brands the data API; auth verification links still expose <project-ref>.supabase.co either way, so I pointed VITE_SUPABASE_URL at the plain project-ref URL and kept the ten dollars.
4C. Deploy the clean edge functions
Most functions ported cleanly and just needed the SUPABASE_SECRET_KEYS change from Step 2, then a deploy:
supabase functions deploy invite-user --no-verify-jwt
# ...and the rest of the clean functions, all with --no-verify-jwt
Declare every function in supabase/config.toml while you are here. Supabase’s GitHub integration only auto-deploys functions listed there, and, more importantly, a branch is built entirely from the repo. Functions you deployed to production by hand are invisible to branches. This one line of foresight would have saved me a headache in Step 6; I only half-learned it here and paid for the other half in the rehearsal.
config.toml: the file in your repo that tells Supabase which edge functions and settings to deploy. If a function is listed here, every branch and every deploy gets it automatically. If it only exists because you clicked around the dashboard or ran a one-off CLI deploy, branches will not know about it.
4D. Rewrite the auth email hook (the Lovable-specific one)
The one function that fought back was the auth email hook. On Lovable it used vendor-specific @lovable.dev/ packages and dropped messages onto a pgmq queue for a separate worker (process-email-queue) to drain, none of which runs off-platform. On your own Supabase, nothing drains that queue unless you rebuild Lovable’s whole email infrastructure, which you should not.
I rewrote it to stand alone:
-
Verify the incoming webhook signature with the
standardwebhookslibrary (the open standard Supabase’s Send Email hook signs with). -
Parse Supabase’s native hook payload; there is no pre-built confirmation URL in it.
-
Build the verification URL yourself:
https://<project-ref>.supabase.co/auth/v1/verify?token=${token_hash}&type=${email_action_type}&redirect_to=${redirect_to} -
Send synchronously through Postmark. Do not enqueue.
-
Handle
email_changecorrectly: the email sent to the current address usestoken_hash_new, and the email sent to the new address usestoken_hash. It reads backwards, it is documented, and getting it wrong silently breaks confirmations.
The old pgmq-based worker became dead code I simply declined to deploy.
The porting rule of thumb: your schema and business logic move almost for free; the glue your old host wrote for you is what you rebuild. Budget your time there, not on the tables.
4E. Register the Send Email hook (a one-minute, sequence-sensitive trap)
Send Email hook: a setting that tells Supabase to call your function whenever it needs to send an auth email (an invite, a password reset, and so on), instead of sending its own generic one. It is what lets every one of those emails match your brand and go out through your provider.
Registering the hook is sequence-sensitive. Between creating the hook and setting its signing secret, every auth email 500s on the signature check, so you move fast and do the whole thing in under a minute:
-
Dashboard, Authentication, Hooks, Send Email, type HTTP.
-
URL:
https://<project-ref>.supabase.co/functions/v1/auth-email-hook -
Save, then copy the
v1,whsec_…secret now. It is shown once and not retrievable later. -
Immediately:
supabase secrets set SEND_EMAIL_HOOK_SECRET=<the v1,whsec_… value> supabase functions deploy auth-email-hook --no-verify-jwt
The
v1,whsec_…secret: the webhook signing secret Supabase uses to prove the hook call is genuine, so nobody else can trick your function into sending email.
4F. Bootstrap a test admin and test every email flow
You need at least one user to test with:
-
Dashboard, Authentication, Users, Add user.
-
SQL Editor, promote yourself (adjust to your own roles table):
UPDATE public.user_roles SET role = 'admin' WHERE user_id = '<your-uuid>';
Then trigger every auth email and click the actual links: signup, invite, magic link, recovery, reauthentication, and both directions of email change. The full end-to-end test lands best after Step 5, because the links need a working frontend to arrive at, but do not skip it. “The email sent” is not the test; “the link completed the flow” is.
Done when: I can log in as a bootstrapped admin, and all the auth emails arrive and their links complete.
Step 5: Deploy the frontend to Vercel
This is the quiet step. I linked the repo to Vercel, set two environment variables, and deployed:
vercel link
# Vercel dashboard, Settings, Environment Variables (Production):
# VITE_SUPABASE_URL = https://<new-project-ref>.supabase.co
# VITE_SUPABASE_PUBLISHABLE_KEY = sb_publishable_...
Vercel deploys and environment variables: Vercel builds your
mainbranch to production (your real domain) and every other branch to a preview (a throwaway URL). Environment variables are per-deployment settings, like which database URL and key to use, so production and preview can safely point at different backends. That split is exactly what later lets mydevbranch have its own live site on my dev database without ever touching production.
The Vite build reads these at build time, so a wrong value here means a site that loads but cannot talk to its backend. Remember the paste-level detail from Step 2: the publishable key is the plain default string, not the JSON wrapper around it.
Then do the one thing here that pays off on cutover night: pre-register your production domain in Vercel, Settings, Domains, even though DNS still points at Lovable. Vercel shows “Invalid Configuration,” which is expected and fine. What you are buying is the TLS certificate, provisioned ahead of time, so the eventual DNS flip activates instantly instead of showing users a security warning while a cert is issued. I skipped this on my first pass and cutover night gained an extra step it did not need. Do it here.
DNS and the TLS cert, in one line: DNS is the address book that turns your domain name into the actual server it points at, so changing one record repoints the domain from Lovable to Vercel. The TLS certificate is what makes the site load as
https; pre-adding the domain gets that certificate issued ahead of time.
Done when: the build succeeds, the Vercel preview URL shows the login page, and the production domain is added in Vercel (not pointing yet).
Step 6: Rehearse the migration on a Supabase dev branch with real data
An empty environment is a weak test. So instead of handing my client a blank app to click around, I gave them their real data on a Supabase dev branch, and used the same exercise to rehearse the entire production import before it could hurt anything. This step is where the export and import tooling gets built and proven, which is the part most write-ups skip. main stays empty the whole time.
Supabase branch: a near-copy of your project with its own database and URL, built from your repo’s migrations. A persistent branch is long-lived and always reachable, unlike an ephemeral preview branch that spins up per pull request and is torn down.
6A. Stand up a persistent dev branch
- Dashboard, Branching, Enable.
- Create a persistent branch synced to the git
devbranch (about $10 a month). Not a preview branch. The distinction sounds cosmetic. It is not, and Step 7 is entirely about why. - Note the dev branch’s project ref and its
sb_publishable_…key.
The branch comes up with the full schema and zero data. Now the tooling.
6B. Deploy the export function on Lovable
You cannot pg_dump Lovable Cloud; there is no direct Postgres access at all. The self-serve path is a one-shot edge function deployed inside the Lovable project, via Lovable’s own chat:
- Generate a secret locally,
openssl rand -hex 32. This becomesMIGRATION_EXPORT_TOKEN. - Send Lovable a spec for a function that:
- deploys with
verify_jwt = falseand gates itself on anx-migration-tokenheader, compared in constant time - creates a private Storage bucket (for example
migration-export) - supports op-modes:
inventoryandchunk - for
chunk: SELECTs from an explicit allowlist of yourpublic.*tables, serialises the rows as NDJSON, uploads to the bucket, and returns a signed URL
- deploys with
- Use Lovable’s two-phase protocol: review the generated code in chat first, and reply
deployonly when you are satisfied.
One detail worth clocking: this function runs inside Lovable, which is still on legacy keys, so it reads SUPABASE_SERVICE_ROLE_KEY, the exact env var your new project’s functions moved away from in Step 2. Here is the whole thing.
Code — the Lovable export function (drop-in; edit the allowlist)
// export-<random-name>/index.ts
// Runs INSIDE the Lovable project. Auth: constant-time token check, no JWT.
import { createClient } from 'npm:@supabase/supabase-js@2';
const BUCKET = 'migration-export';
// ==== EDIT THIS BLOCK FOR YOUR SCHEMA =======================================
// Every exported table, in FK-safe order, with the column(s) that give a
// stable ORDER BY for chunking. Tables with a plain `id` use ['id'].
// Composite-key tables list their key columns. Audit tables use their real
// timestamp column (verify the name against your schema; a wrong name throws).
const ALLOWLIST: Record<string, string[]> = {
table_1: ['id'],
table_2: ['id'],
table_3: ['id'],
table_4: ['id'],
table_5: ['id'],
table_6: ['id'],
table_7: ['parent_id', 'position'], // composite-key example
table_8: ['changed_at', 'id'], // audit-table example
};
// ============================================================================
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
// Lovable-hosted projects still run on legacy keys, so this env var exists there:
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Constant-time comparison via fixed-length digests, so response timing
// leaks nothing about how much of the token matched.
async function tokenOk(req: Request): Promise<boolean> {
const given = req.headers.get('x-migration-token') ?? '';
const expected = Deno.env.get('MIGRATION_EXPORT_TOKEN') ?? '';
if (expected.length === 0) return false;
const enc = new TextEncoder();
const [a, b] = await Promise.all([
crypto.subtle.digest('SHA-256', enc.encode(given)),
crypto.subtle.digest('SHA-256', enc.encode(expected)),
]);
const av = new Uint8Array(a);
const bv = new Uint8Array(b);
let diff = 0;
for (let i = 0; i < av.length; i++) diff |= av[i] ^ bv[i];
return diff === 0;
}
Deno.serve(async (req) => {
if (!(await tokenOk(req))) {
return new Response('unauthorized', { status: 401 });
}
const { op, table, offset = 0, limit = 5000 } = await req.json();
if (op === 'inventory') {
const tables: Record<string, number> = {};
for (const name of Object.keys(ALLOWLIST)) {
const { count, error } = await supabase
.from(name)
.select('*', { count: 'exact', head: true });
if (error) {
return new Response(`inventory failed on ${name}: ${error.message}`, { status: 500 });
}
tables[name] = count ?? 0;
}
return Response.json({ tables });
}
if (op === 'chunk') {
if (typeof table !== 'string' || !(table in ALLOWLIST)) {
return new Response('table not allowlisted', { status: 400 });
}
let query = supabase.from(table).select('*');
for (const col of ALLOWLIST[table]) {
query = query.order(col, { ascending: true });
}
const { data, error } = await query.range(offset, offset + limit - 1);
if (error) {
return new Response(`chunk failed on ${table}: ${error.message}`, { status: 500 });
}
const rows = data ?? [];
const ndjson = rows.map((r) => JSON.stringify(r)).join('\n');
// Private bucket; createBucket errors harmlessly if it already exists.
await supabase.storage.createBucket(BUCKET, { public: false }).catch(() => {});
const key = `${table}/chunk-${offset}.ndjson`;
const upload = await supabase.storage
.from(BUCKET)
.upload(key, new Blob([ndjson]), { upsert: true, contentType: 'application/x-ndjson' });
if (upload.error) {
return new Response(`upload failed: ${upload.error.message}`, { status: 500 });
}
const signed = await supabase.storage.from(BUCKET).createSignedUrl(key, 3600);
if (signed.error) {
return new Response(`sign failed: ${signed.error.message}`, { status: 500 });
}
return Response.json({ rows: rows.length, signedUrl: signed.data.signedUrl });
}
// vault and auth-meta are deliberately not implemented: no decrypted secret
// ever travels through this pipeline (the vault is hand-carried at cutover).
return new Response('unknown op', { status: 400 });
});
Deeper — why a constant-time token check
The token gate hashes both the supplied and expected token to fixed-length SHA-256 digests and XORs them byte by byte, so the comparison always touches the same number of bytes regardless of where a mismatch is. A naive given === expected can short-circuit on the first differing character, and the tiny timing difference is enough, in theory, to let an attacker recover the token one character at a time. It is a cheap habit for anything that guards data.
Designing the table allowlist is where your schema knowledge earns its keep. For every table you include, verify four things:
- Foreign-key-safe import order. Parents before children: root entities first, then rosters, then junction tables, then child rows, with audit and history tables last.
- Composite primary keys. Tables without an
idcolumn needORDER BYon the composite columns for stable chunking, notORDER BY id. - The right timestamp column for the stability clause on audit tables. Mine used
changed_atwhere I expectedcreated_at; the wrong name throws at export time, so check against the schema, not from memory. - Tables with BEFORE INSERT triggers that stamp or overwrite columns. These are the reason the importer needs replica mode, coming up in 6D.
Vault: exclude it. No decrypted secret leaves Lovable through this pipeline, ever. I handle the vault by hand at cutover, and you will see why that is the better engineering call when we get there.
6C. Run the export driver from your laptop
The driver is a small Node script in the repo, read-only against Lovable:
export MIGRATION_EXPORT_TOKEN=<your-token>
node scripts/migration-export.mjs --skip-vault \
--url "https://<lovable-project-ref>.supabase.co/functions/v1/export-<random-name>" \
--token "$MIGRATION_EXPORT_TOKEN"
It does three things: calls op: inventory to capture per-table row counts (this baseline matters later), loops op: chunk per table at 5,000 rows per chunk, and downloads each signed URL into ./migration-export/<runId>/. --skip-vault is mandatory; the driver refuses to run without it. That is deliberate friction, not an accident.
Code — scripts/migration-export.mjs
#!/usr/bin/env node
// scripts/migration-export.mjs
// Downloads your application data from the Lovable export edge function.
//
// Usage:
// export MIGRATION_EXPORT_TOKEN=<token>
// node scripts/migration-export.mjs --skip-vault \
// --url "https://<lovable-project-ref>.supabase.co/functions/v1/export-<random-name>" \
// --token "$MIGRATION_EXPORT_TOKEN"
//
// Output:
// ./migration-export/<runId>/inventory.json per-table row counts (the baseline)
// ./migration-export/<runId>/<table>.chunk-<n>.ndjson the data, 5000 rows per chunk
//
// Failure exits: 1 bad args, 2 HTTP/download failure, 3 count mismatch vs inventory.
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const CHUNK_SIZE = 5000;
const args = process.argv.slice(2);
const hasFlag = (name) => args.includes(name);
const argValue = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const url = argValue('--url');
const token = argValue('--token') ?? process.env.MIGRATION_EXPORT_TOKEN;
if (!url || !token) {
console.error('usage: migration-export.mjs --skip-vault --url <function-url> --token <token>');
process.exit(1);
}
if (!hasFlag('--skip-vault')) {
console.error('refusing to run without --skip-vault: this pipeline never carries vault secrets.');
process.exit(1);
}
async function callExport(body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-migration-token': token },
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(`export function returned ${res.status}: ${await res.text()}`);
process.exit(2);
}
return res.json();
}
const runId = new Date().toISOString().replace(/[:.]/g, '-');
const outDir = path.join('migration-export', runId);
await mkdir(outDir, { recursive: true });
const { tables } = await callExport({ op: 'inventory' });
await writeFile(path.join(outDir, 'inventory.json'), JSON.stringify({ tables }, null, 2));
console.log('inventory baseline:', tables);
let allMatch = true;
for (const [table, expected] of Object.entries(tables)) {
let downloaded = 0;
for (let offset = 0; offset < Math.max(expected, 1); offset += CHUNK_SIZE) {
const { rows, signedUrl } = await callExport({ op: 'chunk', table, offset, limit: CHUNK_SIZE });
if (rows === 0) break;
const res = await fetch(signedUrl);
if (!res.ok) {
console.error(`download failed for ${table} at offset ${offset}: ${res.status}`);
process.exit(2);
}
await writeFile(path.join(outDir, `${table}.chunk-${offset}.ndjson`), await res.text());
downloaded += rows;
}
const ok = downloaded === expected;
if (!ok) allMatch = false;
console.log(`${table}: downloaded ${downloaded} / expected ${expected} ${ok ? 'OK' : 'MISMATCH'}`);
}
if (!allMatch) {
console.error('downloaded counts do not match the inventory baseline; do NOT import this run.');
process.exit(3);
}
console.log(`export complete. run id: ${runId}`);
6D. Import into the dev branch, in replica mode
The import side is where Postgres will fight you if you let it. My schema has triggers that rewrite values on insert (one stamps a rate onto each row from the current value), and foreign keys that demand a specific insert order. Load naively and your history gets silently re-stamped with today’s values, which is the kind of corruption nobody notices until an invoice is wrong.
session_replication_role = 'replica': a Postgres setting that suppresses triggers and foreign-key checks for the connection, or, withSET LOCAL, for just the current transaction, letting you load data exactly as it was exported. It does not switch offNOT NULLorCHECKconstraints; those still apply, which is what you want.
The subtlety that cost me one bad assumption: session_replication_role is scoped to a session or a transaction, never to a single statement. My first cut set it once and let each INSERT autocommit on its own, and across Supabase’s default transaction pooler (port 6543) those statements can each land on a different backend, so the setting silently stopped applying partway through and triggers fired. The fix wraps the entire load in one transaction and uses SET LOCAL, which pins the flag to that transaction and lets it expire at commit. Inside a single transaction the pooler keeps you on one backend, so that alone closes the hole. I still run it over a session connection (port 5432, or the direct string), though, and the script hard-refuses a :6543 URL as a backstop, in case someone later loosens the single-transaction guarantee. One requirement people miss: setting session_replication_role needs the postgres (owner) role, the one already in the pooler string; a restricted role gets permission denied, so run it once before cutover to confirm your connection can:
await client.query('BEGIN');
await client.query("SET LOCAL session_replication_role = 'replica'"); // this transaction only
try {
// load parents before children (FK-safe order)
// INSERT every column explicitly so no default or trigger clobbers a value
// verify counts INSIDE the transaction; a mismatch rolls everything back
await client.query('COMMIT'); // SET LOCAL expires here on its own
} catch (e) {
await client.query('ROLLBACK');
throw e;
}
The script hard-refuses a :6543 URL for exactly this reason, so you cannot get it wrong by accident. For the rehearsal, run a dry run first, then the real thing with the rehearsal flag:
# Dry-run: parse + FK-order check, no database touched
node scripts/migration-import.mjs --run-id <runId> --dev-rehearsal --dry-run
# Live import into the dev branch (SESSION connection, port 5432)
node scripts/migration-import.mjs --run-id <runId> --dev-rehearsal \
--db-url "postgresql://postgres.<dev-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres"
One sizing note if your tables are wide: the importer batches rows (500 at a time) into multi-row INSERTs, and Postgres caps a single statement at 65,535 parameters. At 500 rows that is fine up to about 130 columns; past that, lower BATCH_SIZE.
The --dev-rehearsal flag applies transforms that exist purely for safety, and they are OFF at the real cutover:
- every user’s email is rewritten to a plus-addressed alias on a mailbox I control, so no invite or cron email path can ever reach a real person from a test environment
user_idforeign keys are set to NULL (they get rebuilt on invite anyway)- the vault table is skipped entirely
Then reconcile per-table row counts against the inventory baseline from 6C, because “the app loads” is not verification and “the counts match” is. The script does this inside the transaction and rolls back on any mismatch, so a bad run writes nothing.
Code — scripts/migration-import.mjs
#!/usr/bin/env node
// scripts/migration-import.mjs
// Loads the exported NDJSON into your new Supabase Postgres.
// Dependency: npm install pg
//
// IMPORTANT: --db-url must be a SESSION connection: port 5432 on the pooler
// host, or the direct connection string. NOT the transaction pooler on 6543:
// session_replication_role will not reliably hold across statements there,
// so triggers you believed were suppressed can silently fire. This script
// refuses a :6543 URL for that reason.
//
// Usage:
// node scripts/migration-import.mjs --run-id <runId> --dev-rehearsal --dry-run
// node scripts/migration-import.mjs --run-id <runId> --dev-rehearsal --db-url "postgresql://...:5432/postgres"
// node scripts/migration-import.mjs --run-id <runId> --db-url "postgresql://...:5432/postgres" # cutover
//
// Failure exits: 1 bad args/config, 2 files missing, 3 DB error (rolled back), 4 count mismatch (rolled back).
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
// ==== EDIT THIS BLOCK FOR YOUR SCHEMA =======================================
// Every table from the export, in FK-safe order (parents before children,
// junction tables after both parents, audit/history last). Must cover every
// table in inventory.json; the script refuses to run otherwise.
const TABLES = [
'table_1', 'table_2', 'table_3', 'table_4',
'table_5', 'table_6', 'table_7', 'table_8',
];
// Dev-rehearsal transforms: which table.column holds user emails / user_ids.
const EMAIL_COLUMNS = { table_2: 'email' };
const USER_ID_COLUMNS = { table_2: 'user_id' };
// Rehearsal emails become <local>+mig<n>@<domain> on a mailbox you control:
const REHEARSAL_MAILBOX = 'you@your-mailbox.example';
// Tables that must never pass through this pipeline:
const VAULT_TABLES = ['vault_secrets'];
const BATCH_SIZE = 500;
// ============================================================================
const args = process.argv.slice(2);
const hasFlag = (name) => args.includes(name);
const argValue = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const runId = argValue('--run-id');
const dbUrl = argValue('--db-url');
const rehearsal = hasFlag('--dev-rehearsal');
const dryRun = hasFlag('--dry-run');
if (!runId || (!dryRun && !dbUrl)) {
console.error('usage: migration-import.mjs --run-id <runId> [--dev-rehearsal] [--dry-run] [--db-url <session-url>]');
process.exit(1);
}
if (dbUrl && dbUrl.includes(':6543')) {
console.error('refusing :6543 (transaction pooler): session_replication_role needs a session connection. Use port 5432 or the direct connection.');
process.exit(1);
}
if (!rehearsal) {
console.warn('CUTOVER MODE: no transforms will be applied; real emails load as-is.');
}
const dir = path.join('migration-export', runId);
let inventory;
try {
inventory = JSON.parse(await readFile(path.join(dir, 'inventory.json'), 'utf8')).tables;
} catch (err) {
console.error(`cannot read ${dir}/inventory.json: ${err.message}`);
process.exit(2);
}
// Config coverage check: every exported table must be placed in FK order.
const missing = Object.keys(inventory).filter((t) => !TABLES.includes(t));
if (missing.length > 0) {
console.error(`inventory tables missing from TABLES config (add them in FK order): ${missing.join(', ')}`);
process.exit(1);
}
const vaultHit = Object.keys(inventory).filter((t) => VAULT_TABLES.includes(t));
if (vaultHit.length > 0) {
console.error(`vault tables present in export; this pipeline refuses them: ${vaultHit.join(', ')}`);
process.exit(1);
}
async function loadRows(table) {
const files = (await readdir(dir))
.filter((f) => f.startsWith(`${table}.chunk-`) && f.endsWith('.ndjson'))
.sort((a, b) => chunkOffset(a) - chunkOffset(b));
const rows = [];
for (const file of files) {
const text = await readFile(path.join(dir, file), 'utf8');
for (const line of text.split('\n')) {
if (line.trim().length > 0) rows.push(JSON.parse(line));
}
}
return rows;
}
function chunkOffset(filename) {
return Number(filename.match(/chunk-(\d+)\.ndjson$/)?.[1] ?? 0);
}
function applyRehearsalTransforms(table, rows) {
const emailCol = EMAIL_COLUMNS[table];
const userIdCol = USER_ID_COLUMNS[table];
if (!emailCol && !userIdCol) return rows;
const [local, domain] = REHEARSAL_MAILBOX.split('@');
return rows.map((row, i) => {
const out = { ...row };
if (emailCol && out[emailCol]) out[emailCol] = `${local}+mig${i}@${domain}`;
if (userIdCol) out[userIdCol] = null;
return out;
});
}
// node-postgres passes primitives through; objects/arrays are stringified so
// json/jsonb columns load exactly as exported. If your schema uses native
// Postgres array columns, adjust this function for those columns.
function toParam(value) {
if (value !== null && typeof value === 'object') return JSON.stringify(value);
return value;
}
// Gather everything first, so dry-run and live share one code path.
const plan = [];
for (const table of TABLES) {
if (!(table in inventory)) continue; // in TABLES but not exported: fine, skip
let rows = await loadRows(table);
if (rehearsal) rows = applyRehearsalTransforms(table, rows);
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
plan.push({ table, rows, columns });
console.log(`${table}: ${rows.length} rows, ${columns.length} columns${rehearsal ? ' (rehearsal transforms applied)' : ''}`);
}
if (dryRun) {
console.log('dry run complete: files parsed, config covers inventory, no database touched.');
process.exit(0);
}
const client = new pg.Client({ connectionString: dbUrl });
await client.connect();
try {
await client.query('BEGIN');
// Triggers + FK checks off for THIS TRANSACTION ONLY; expires at COMMIT/ROLLBACK.
await client.query("SET LOCAL session_replication_role = 'replica'");
for (const { table, rows, columns } of plan) {
if (rows.length === 0) continue;
const colList = columns.map((c) => `"${c}"`).join(', ');
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const params = [];
const tuples = batch.map((row, r) => {
const placeholders = columns.map((col, c) => {
params.push(toParam(row[col]));
return `$${r * columns.length + c + 1}`;
});
return `(${placeholders.join(', ')})`;
});
// Every column emitted explicitly: no default, no trigger, no clobber.
await client.query(
`INSERT INTO public."${table}" (${colList}) VALUES ${tuples.join(', ')}`,
params,
);
}
}
// Verify INSIDE the transaction: a mismatch rolls everything back.
let allMatch = true;
for (const { table } of plan) {
const { rows: [{ count }] } = await client.query(`SELECT count(*)::int AS count FROM public."${table}"`);
const expected = inventory[table];
const ok = count === expected;
if (!ok) allMatch = false;
console.log(`${table}: loaded ${count} / expected ${expected} ${ok ? 'OK' : 'MISMATCH'}`);
}
if (!allMatch) {
await client.query('ROLLBACK');
console.error('count mismatch: transaction rolled back, nothing was written.');
process.exit(4);
}
await client.query('COMMIT'); // SET LOCAL expires here on its own
console.log('import committed and verified.');
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error(`database error, transaction rolled back: ${err.message}`);
process.exit(3);
} finally {
await client.end();
}
6E. Point the Vercel preview at the dev branch
- Vercel, Settings, Environment Variables, scope Preview:
VITE_SUPABASE_URL= the dev branch URLVITE_SUPABASE_PUBLISHABLE_KEY= the dev branch key
- Deploy from git
dev. The client tests on the stable…-git-dev-….vercel.appalias. - Keep the gate: signups stay OFF, and the preview carries noindex.
6F. The bugs the rehearsal handed me
Incident log: Appendix C, incident 2, incident 3, incident 4, incident 5.
Then the rehearsal started paying for itself, which is exactly what a rehearsal is for.
Bug 1: the invite button failed, and the function was nowhere. Clicking invite in the dev app threw a CORS preflight error. The cause was not CORS. Several edge functions had only ever been deployed to production by hand, and were never declared in config.toml, so the dev branch, built from the repo, had never deployed them. They existed on no branch at all. I declared all of them in config.toml, which fixed the branch and made every future promotion deploy the identical set. A smaller cousin persisted after that: the preview URL was missing from the edge functions’ CORS allowlist, so I added the Vercel dev alias to the shared CORS helper.
The mental model: a branch reproduces only declarative repo state. Anything you did by hand to production, a CLI deploy, a dashboard toggle, a secret set once, is invisible to branches. Declare it in the repo, or re-apply it per branch.
Bug 2: the first invite email came from the wrong sender. It arrived from noreply@mail.app.supabase.io, Supabase’s default, not from Postmark. Branches inherit neither Auth hooks nor custom secrets; only the platform SUPABASE_* secrets are auto-injected. So I re-set the four email secrets on the branch and re-created the Send Email hook against the branch’s own function URL. While I was in there I fixed the branch’s Auth URL config too, because a fresh branch defaults its Site URL to localhost, and every email link had been pointing there.
That incident produced the per-branch checklist. None of this is inherited; redo all of it every time a branch is created or rebuilt:
POSTMARK_SERVER_TOKEN,EMAIL_FROM,CRON_SECRET,SEND_EMAIL_HOOK_SECRET- Send Email hook pointed at the branch’s own
auth-email-hookURL - Auth URL configuration: Site URL plus the redirect allowlist
Edge functions, on the other hand, are inherited, provided they are declared in config.toml.
Bug 3: the dev site was quietly talking to production. While wiring the preview env vars, I pasted the dev Supabase URL into the key field and left the URL field to fall back to the production value. The static bundle looked ambiguous when I grepped it: two URL-ish strings and no obvious key. The smoking gun was the live request. The apikey header the running site actually sent was a URL, not a key, and after I fixed both fields the error signature changed from 401 (invalid key) to 400 (valid key, wrong password on my test login), which is the change you want to see.
Verify env wiring at runtime, not by reading dashboard fields. Look at the request the running system sends, not at what the config says it should send.
There was one more small find in the same family: a committed .env still pointed at the retired Lovable project. It only affected local builds (Vercel’s dashboard vars override it), but I untracked it, gitignored it, and left a .env.example template. The exposed value was a public publishable key, so no rotation was needed.
6G. Bootstrap test users and get sign-off
Add an admin plus one or two test users in the dev branch’s Auth, promote the admin in the roles table, and invite test addresses only. Then walk my client through the functional checklist:
- Login works
- The UI renders with real-shaped data
- Creating test records works
- Invite a test user, set a password, log in as them
- Every critical write path (time logging and the like)
- The browser extension against the dev backend (a side-loaded build)
- A vault encrypt/decrypt round trip on the new project’s vault (fresh data, not migrated)
- The scheduled email job, run manually in
test_emailmode
Done when: the client confirms, in writing, that the app behaves correctly against their real data. The snapshot itself is throwaway; the real cutover re-exports fresh from Lovable.
Step 7: Protect your Supabase dev branch (the merge that deleted mine)
Incident log: Appendix C, incident 6.
Then came the incident I tell people about first. I promoted dev to main through a pull request, a routine merge (a code-and-docs promotion; main still held no data at this point), and when it completed my entire dev environment was gone. The branch, its data, its backups.
Two cleanup behaviours had fired at the same instant. Supabase deletes a preview branch when its PR merges, and my dev branch, despite my intent, had come up as a preview rather than a persistent one. At the same time, GitHub’s “Automatically delete head branches” setting deleted the dev branch on the repo side. I opened a support ticket hoping for a restore. Supabase confirmed it was permanently unrecoverable: a deleted branch is an isolated instance, so the parent project’s backups never held its data, and the deleted instance’s own backups go with it.
The fixes are boring, which is the point:
- The Supabase dev branch must be persistent. Verify it before every promotion;
supabase branches listmust showpersistent=Truefor the branch, because a preview branch is disposable by design. - GitHub’s auto-delete head branches must be off (
deleteBranchOnMerge=false), or the git side of your dev environment vanishes on merge too. - Keep an off-platform backup of anything you cannot rebuild from migrations. A hand-imported data snapshot qualifies.
I rebuilt the branch as persistent the same day, re-exported from Lovable, re-imported, re-applied the per-branch checklist from Step 6 (secrets, Send Email hook, Auth URLs), and then did the thing that turns a scar into a safeguard: I ran a deliberate dev-to-main test merge and watched the branch and its data survive intact. Only then did I trust the promotion path for the real cutover.
Done when: the dev branch is persistent, auto-delete is off on GitHub, and a test merge has proven the branch survives promotion.
Step 8: Cutover night: freeze writes, import into main, flip the DNS
With the path proven, the real thing. This is the highest-risk step and the order matters, so here it is exactly as I ran it, inside a scheduled maintenance window.
Table names in the SQL below:
table_1 … table_Nare placeholders for your real tables. Where one specific table matters (your users table, say), I call it out by its role.
8.1 Sanity-check that main is empty
On the new project (triple-check you are not on Lovable):
-- Adjust the table list to your schema. (All-UUID primary keys here, so no RESTART IDENTITY needed.)
TRUNCATE public.table_1, public.table_2, public.table_3 CASCADE;
-- If you use vault:
DELETE FROM vault.secrets WHERE name LIKE 'your_prefix_%';
Confirm zero rows before the import. Any leftovers from testing would collide with the preserved UUIDs the import carries over.
8.2 Freeze Lovable writes with two locks
After I take the export snapshot, not one new write may land on Lovable, or it strands on a system I am about to retire. Disabling buttons in the UI is not enough, because a left-open browser tab and the companion browser extension both write straight to the database through PostgREST without touching those buttons.
The real freeze is at the database, not the UI. A UI banner is a courtesy; revoking write permission is the guarantee.
Lock 1, the courtesy: via Lovable chat, a UI-only maintenance banner with the write actions disabled, so users see a friendly state. Reads still work.
Lock 2, the guarantee: run on Lovable’s database:
-- Removes write permission from every logged-in user, on every application table.
-- Does NOT change data, does NOT block reads, does NOT log anyone out.
REVOKE INSERT, UPDATE, DELETE ON
public.table_1, public.table_2, public.table_3, public.table_4,
public.table_5, public.table_6, public.table_7, public.table_8
FROM authenticated;
It changes permissions, not data, so the site goes read-only rather than down, and the rollback is the inverse GRANT, effective instantly with no deploy:
GRANT INSERT, UPDATE, DELETE ON public.table_1, public.table_2, /* ... */ TO authenticated;
I spot-checked the lock by trying to write from the extension, the one path the banner cannot cover, and got the 403 I wanted.
8.3 Export fresh, import into main, reconcile
Incident log: Appendix C, incident 7.
One pre-flight surprise first: the export function from the rehearsal had been deleted from Lovable in the weeks between. I redeployed it via Lovable chat and smoke-tested it (an inventory call, plus a 401 check without the token) before the window opened. Check yours before you need it.
Export, same driver, fresh run:
node scripts/migration-export.mjs --skip-vault \
--url "https://<lovable-ref>.supabase.co/functions/v1/export-<n>" \
--token "$MIGRATION_EXPORT_TOKEN"
Import into main, and this time without --dev-rehearsal, over a session connection (port 5432):
node scripts/migration-import.mjs --run-id <runId> \
--db-url "postgresql://postgres.<main-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres"
The script prints a cutover-mode warning, because real emails now load as-is.
Reconcile row counts, table by table, against the fresh Lovable inventory. The script already verifies this inside the transaction and rolls back on a mismatch, but treat the printed result as a hard gate anyway: do not touch DNS until every count matches.
8.4 Hand-carry the vault
My instinct had been to build a decrypt-then-re-encrypt pipeline for the credential vault; then I counted the rows and there were two. Building a pipeline to move a couple of secrets would mean writing plaintext to a file or the wire, exactly what a vault exists to prevent. So: read the secrets in the live Lovable app before the flip, write them on paper, re-enter them in the new app after the flip, verify with a reveal-check, destroy the paper. I never called the export function’s vault op at all (it is not even implemented), and no decrypted secret ever touched a disk. Sometimes the right engineering decision is to not build the thing.
8.5 Flip the DNS
A record vs CNAME: an A record points a hostname directly to an IP address; a CNAME points it to another hostname that resolves to the final IP. Your registrar will have one of the two pointing at Lovable today.
- The production domain is already added in Vercel from Step 5 (if you skipped that, do it now and wait for the cert).
- Write down the current DNS target, A record or CNAME, exactly as it is. That line of paper is your rollback.
- Change the record at the registrar to Vercel’s target, IP or CNAME per Vercel’s instruction. Mine was GoDaddy, and the DNS sat behind a second admin’s 2FA, so I had scheduled the window for a time they could relay the code live. Know your own bottleneck before the night.
- Wait out propagation, roughly ten to thirty minutes. Verify:
curl -I https://your-app.example.comreturnsserver: Vercelwith a valid certificate. - Runtime check, same discipline as the rehearsal: log in on the live domain and confirm the request hits the main project ref with a valid publishable key.
8.6 Verify against the old numbers before touching users
- The dashboard loads with the expected data.
- The key computed figures are byte-identical to the pre-flip snapshot. This matters more than it sounds: those figures flow through the write-time triggers, so identical totals prove the replica-mode import preserved historical values instead of re-stamping them.
- The vault decrypts (checked manually, off any logs).
If anything here is badly wrong, roll back the DNS now (Step 9 has the full sequence) and do not proceed to re-invites. A rollback before re-invites is invisible; after, it is not.
8.7 GitHub Actions secrets and the scheduled jobs
Incident log: Appendix C, incident 8.
The scheduled jobs need to aim at the new project. Supabase secrets are write-only (you cannot read one back to compare), so do not try to “match” an existing value; generate one fresh secret and set the same value in both places so they cannot drift:
CRON_SECRET=$(openssl rand -hex 32)
gh secret set CRON_SECRET --body "$CRON_SECRET"
gh secret set SUPABASE_FUNCTIONS_URL --body "https://<main-ref>.supabase.co/functions/v1"
supabase secrets set CRON_SECRET="$CRON_SECRET" --project-ref <main-ref>
This is where the night’s second surprise lived: CRON_SECRET on main did not match GitHub, because the dev branch had been given its own value at some point and the two had drifted (branches do not share custom secrets, remember). Setting one fresh value in both places ends the drift for good.
Verify the cron function with test mode, one email to yourself, never a full production blast:
curl -sS -X POST "https://<main-ref>.supabase.co/functions/v1/<your-cron-function>" \
-H "Authorization: Bearer $CRON_SECRET" \
-H "Content-Type: application/json" \
-d '{"test_email":"you@example.com"}'
Keep the production cron workflow disabled during the migration window; re-enable it after the re-invites are done.
8.8 Re-invite every user, and the trap that blocked it
Incident log: Appendix C, incident 9.
Here is the part nobody wants to hear: auth does not come across. Lovable exposes no direct database access, so the password hashes in auth.users never leave it, and every user creates fresh credentials on the new system through an invite. (A Supabase-to-Supabase move with database access could carry the hashes across; Lovable gives you none, which is what forces the re-invite here.)
Why re-invite is required: without the original password hashes, there is nothing to import that would make an existing password work. Users must set a new one.
Two preparations before the burst:
Raise the auth email rate limit. Dashboard, Authentication, Rate Limits. The default was 2 per hour, which is uselessly low for a bulk re-invite. I set it to 100 per hour. Stagger the invites if your user count needs it.
Clear the leftover column. My first re-invites failed for everyone who had a Lovable account. The invite function refuses any user whose user_id is already set, and my import had faithfully preserved the old, now-meaningless user_id from Lovable. The fix, on your users table (here table_2):
UPDATE public.table_2 SET user_id = NULL WHERE user_id IS NOT NULL;
-- Re-link your own admin row afterward if you are both admin and a user
Then, on the live app (now serving from Vercel), invite each user. The invite links use the request Origin, so they land on the production domain. One more thing that does not come along for the ride: user_roles. Promote your admins manually after their invites complete:
UPDATE public.user_roles SET role = 'admin'
WHERE user_id IN (SELECT id FROM auth.users WHERE email IN (...));
8.9 Resubmit the browser extension (if you have one)
The extension build is environment-aware: a prod build (VITE_APP_ENV=prod) bakes the live Supabase URL and key in at bundle time. Before uploading, sanity-check the ZIP: config.js shows the main project ref, no “(DEV)” suffix anywhere, correct host_permissions.
On permissions, one deliberate choice: the manifest’s host_permissions declare both the old Lovable host and the new one. That keeps the backend swap inside an already-granted permission set, so Chrome does not disable the extension for existing users when they update. The dead Lovable host gets removed later, in the Step 9 cleanup, once nobody needs the transition anymore.
Then the honest caveat. Chrome Web Store review takes seven to fourteen days, and during that gap, users on the store build still point at the now-frozen Lovable and their writes fail. Web users are fine the moment DNS lands. I told people plainly: use the web app for now, update the extension when the new version clears review.
Done when: data is in main with counts verified, DNS points at Vercel, users are re-invited, admins are promoted, the extension is submitted, the cron job is verified in test mode, and the production workflow is re-enabled.
Step 9: Monitor, keep the rollback warm, and retire Lovable after 30 days
9.1 Watch the things that fail quietly
For the next 24 to 48 hours I watched: first-time logins hitting the new set-password flow, email delivery in the Postmark dashboard, any report of a missing record, and extension complaints during the store review gap. My client ran their own verification pass on live data too: core entities present, recent activity intact, vault decrypts, new writes land.
9.2 The rollback sequence, ready the whole time
I never needed it, but this exact sequence stayed on paper next to me, and knowing it was cheap is what let me sleep:
- DNS back to the recorded Lovable target.
- Lovable Lock 1: revert the UI maintenance state.
- Lovable Lock 2:
GRANTthe writes back. - Revert the GitHub Actions secrets to the Lovable-era values.
- Fix whatever went wrong on the new stack, then retry when ready.
This is also why the 30-day rule exists. Email Lovable support and ask them to keep the old project available as your rollback net; a rollback in week three is only possible if the thing you would roll back to still exists.
9.3 Final cleanup, after 30 boring days
Only after the new system had been uneventful for about a month did I retire the scaffolding, and the list is worth running in full because half of it is security hygiene:
- Delete the export function and its token from Lovable. It authenticates with a static header and can read your data via the service role; it must not outlive its purpose.
- Empty and delete the
migration-exportStorage bucket on Lovable. Those NDJSON chunks are your entire database in plain rows. - Remove
https://*.vercel.app/**from the Auth redirect allowlist. It was a testing convenience, and it is far too broad to keep in production. - Delete the Lovable-era dead code: the old
process-email-queueworker and any orphanedpgmqtables, now that email sends synchronously. - Remove the domain from the Lovable project.
- Narrow the extension’s
host_permissionsto drop the dead Lovable host, now that every user has updated past the transition build. - Retire the Lovable project itself.
Done when: stable for 30+ days, Lovable retired, and no migration artifact left behind that could read or leak your data.
What the move actually bought me
The database is mine now, with real branching and a persistent dev environment that mirrors production. Schema changes flow from a feature branch to dev to a dev-to-main PR, each landing in the matching Supabase branch automatically. Cutover was zero user-visible downtime, and rollback was one DNS record away at every moment that mattered.
If you compress everything above into one transferable idea, it is this: stage the risk into small reversible steps, rehearse the dangerous part against real data somewhere safe, and never give up your escape hatch until you are sure. The dev branch I lost, the env var I fat-fingered, the functions that lived on no branch, none of those became disasters, because each sat behind a step I could stop at or roll back from.
Appendix A: Rollback cheat sheet
| When | Action |
|---|---|
| Steps 1 to 7 | Just stop. Lovable is untouched and still serving production. |
| Step 8, after DNS, before Lovable cleanup | DNS back to the recorded target, GRANT writes back on Lovable, revert the maintenance UI, revert GitHub secrets |
| After the 30-day cleanup | Slower and partial; the Lovable project may be gone. This is why cleanup waits. |
Appendix B: The export/import tooling at a glance
Why two pieces: Lovable has no pg_dump, so the export must run inside Lovable with service-role access, while the import runs on your laptop against the new Supabase over a session Postgres connection. Full source is in the Code drop-downs under Steps 6B, 6C, and 6D.
| Piece | Where it runs | Purpose |
|---|---|---|
export-<random> edge function | Inside the Lovable project | SELECT allowlisted tables, serialise NDJSON, upload to a private bucket, return signed URLs (Step 6B) |
scripts/migration-export.mjs | Your laptop | Inventory baseline, chunk loop, download (Step 6C) |
scripts/migration-import.mjs | Your laptop | FK-ordered load in one transaction under SET LOCAL session_replication_role = 'replica', verify, roll back on mismatch (Step 6D, Step 8.3) |
And the flags that matter:
| Flag | When |
|---|---|
--skip-vault | Always, on every automated export. The vault is hand-carried (Step 6C, Step 8.3). |
--dev-rehearsal | Step 6 only: aliases emails, nulls user_id, skips vault. |
--dry-run | Before any live import: parse and FK-order check with no database (Step 6D). |
omit --dev-rehearsal | Step 8 cutover only: real emails load as-is. |
Appendix C: Incident log (the postmortem index)
Every failure, in one place, symptom first, in the order they hit me.
| # | Step | Symptom | Root cause | Fix |
|---|---|---|---|---|
| 1 | 4B | Configured an email provider the app never called | Assumed Resend + Postmark; only Postmark was wired | Grep the codebase for the provider before configuring anything |
| 2 | 6F | Invite button failed with a CORS preflight error | Functions were hand-deployed to production only, so the branch had none of them | Declare every function in config.toml; add the dev origin to the CORS allowlist |
| 3 | 6F | First invite email came from Supabase’s default sender | Branches inherit neither Auth hooks nor custom secrets | Per-branch checklist: re-set the four email secrets, re-create the Send Email hook, fix the Auth URLs |
| 4 | 6F | Dev site was silently talking to production | The dev URL was pasted into the key env-var field | Fix both fields; verify wiring at runtime via the apikey request header |
| 5 | 6F | Local builds pointed at the retired Lovable project | A committed .env with a stale URL | Untrack and gitignore .env; ship a .env.example |
| 6 | 7 | Entire dev branch and its data deleted on a PR merge | Preview branch + GitHub auto-delete-head both fired | Persistent branch, deleteBranchOnMerge=false, off-platform backups |
| 7 | 8.3 | Export function missing right before cutover | It had been deleted from Lovable in the interim | Redeploy and smoke-test it before the window opens |
| 8 | 8.7 | CRON_SECRET on main did not match GitHub | Per-branch secret drift, and secrets are write-only so you cannot compare | Generate one fresh value, set it in both places |
| 9 | 8.8 | Re-invites failed for every existing user | Import preserved a user_id the invite function refuses to overwrite | UPDATE ... SET user_id = NULL before inviting |
Related reading
- How I store client secrets in Supabase Vault: encrypt at rest, one door to decrypt
- Phased migrations with per-phase verification gates
- Lovable’s BOLA disclosure and my 6-hour key rotation
If you are staring down a migration off Lovable (or any managed host) and want a second pair of eyes on the cutover plan before you flip anything, let’s talk.
Frequently asked questions
Can I keep Lovable as the frontend host and only move the database?
Yes. That is the lower-risk backend-only path: stand up your own Supabase, export the data, and repoint Lovable's Supabase integration at the new project from its editor. I chose the full exit (Vercel plus a DNS flip) because it makes rollback a DNS change I control, not a dependency on Lovable's console cooperating at the exact moment of cutover. I did not execute the backend-only path myself, so treat its reconnect step as the one part to verify against Lovable's current UI before you rely on it.
Do I have to change my code when moving to a brand-new Supabase project?
Yes, a little, if you take the modern API keys (sb_publishable_ / sb_secret_) that new projects issue. Two things change. Every edge function that read the service-role key from SUPABASE_SERVICE_ROLE_KEY now parses it out of the SUPABASE_SECRET_KEYS JSON instead, and every function must be deployed with --no-verify-jwt, because the gateway otherwise tries to parse the opaque token as a JWT and returns 401. Your functions still authenticate the caller themselves; only the automatic gateway check is turned off. The frontend just reads the new publishable key from an env var.
Why can I not just pg_dump the Lovable database?
Lovable Cloud does not expose direct Postgres access, so there is no psql and no pg_dump. The self-serve path is a one-shot edge function deployed inside the Lovable project that SELECTs your tables via the service role, writes NDJSON chunks to a private Storage bucket, and returns signed URLs. A small driver script on your laptop downloads the chunks from there. The full source of both is in this article.
How do I bulk-import data into Postgres without triggers and foreign keys fighting me?
Load over a SESSION Postgres connection (port 5432 or the direct string, never the 6543 transaction pooler), wrap the load in one transaction, and SET LOCAL session_replication_role to 'replica' so triggers and foreign-key checks are suppressed for that transaction only. Insert tables in foreign-key-safe order, emit every column explicitly so write-time triggers don't clobber historical values, verify counts inside the same transaction, and roll back on any mismatch.
Why did re-inviting my users fail right after the cutover?
Because the invite function refused any user whose user_id was already set, and my import had faithfully preserved the old (now meaningless) user_id from Lovable. The fix is to null out user_id on every migrated user before re-inviting, so the invite function creates a fresh account and links it. Also raise your Supabase email rate limit first: the default is about two per hour, far too low for a re-invite burst.
Why did my Supabase branch not have my edge functions or send my branded emails?
Because a branch only reproduces declarative repo state: migrations, config.toml, seed.sql. Functions I had deployed to production by hand from the CLI existed on no branch at all, so I declared all of them in config.toml. Branches also do not inherit Auth hooks or custom secrets, so the first invite from my dev branch came from Supabase's default sender until I re-created the Send Email hook and re-set the email secrets on the branch. Anything done imperatively to production is invisible to branches; declare it in the repo or re-apply it per branch.
Should I migrate encrypted secrets through the same pipeline as the rest of my data?
If the volume is tiny, no. My vault held only a couple of secrets, so I hand-carried them: I read them in the old app before the flip and re-entered them in the new app after, letting the new project encrypt them under its own key. That removed a whole decrypt-transfer-re-encrypt pipeline and guaranteed no plaintext ever hit a file or storage. My export and import scripts refuse to touch the vault at all.