Moving an existing project onto your own server.
The database part is well documented and genuinely straightforward. Take three dumps
through the Supabase CLI rather than raw pg_dump, because the CLI
excludes internal schemas, strips reserved roles, and adds idempotent clauses;
raw pg_dump output includes Supabase internals and causes permission
errors on restore.
supabase db dump --db-url "$PLATFORM_URL" -f roles.sql --role-only
supabase db dump --db-url "$PLATFORM_URL" -f schema.sql
supabase db dump --db-url "$PLATFORM_URL" -f data.sql --use-copy --data-only
Restore into the self-hosted instance in one transaction, with triggers disabled for
the data load so that things like encrypted columns are not processed twice:
psql --single-transaction \
--variable ON_ERROR_STOP=1 \
--file roles.sql \
--file schema.sql \
--command 'SET session_replication_role = replica' \
--file data.sql \
--dbname "$SELF_HOSTED_URL"
Then verify before you believe it: \dt public.* for the tables,
select count(*) from auth.users; for the accounts, and
select * from pg_extension; against the same query on the old project,
since any non-default extension has to be enabled on the new instance first.
What the dump does not carry is the part that bites. Your schema, data, roles, RLS
policies, functions, triggers, and auth.users all come across. JWT
secrets and API keys, auth provider settings, edge functions, storage objects, SMTP
configuration, and DNS do not. The user-visible consequence is worth planning as an
event rather than discovering as an outage: JWT secrets differ between the platform
and your instance, so tokens issued by the old project stop being valid and every
signed-in user has to authenticate again. OAuth redirect URLs in Google, Apple, and
GitHub consoles need repointing away from the platform hostname at the same moment.
If you are coming from Lovable Cloud, the
migration guide covers the export
side and the password-reset choreography that goes with it.
Rehearse the restore on a throwaway stack first, because version skew is the usual
failure. A managed project running Postgres 17 can emit
SET transaction_timeout = 0, which fails on an older self-hosted image,
and COPY blocks for tables newer Auth and Storage versions have but
yours does not, such as auth.oauth_clients,
storage.buckets_vectors, and storage.vector_indexes. The
documented technique is to run the restore without
--single-transaction first to collect every failure at once, comment out
the offending statements, then do the real run with the transaction back on. Keeping
your self-hosted version current is what stops this list from growing.
You are not saving money, you are changing who is on call
Self-hosting is the right answer when control or a compliance requirement makes it
the only answer, and a bad one when the goal is a smaller invoice. The install is
fifteen minutes; the monthly release, the breaking changes, the backups nobody takes
for you, and the restore you have to prove are permanent. Decide with that column
filled in.