Switching from Supabase to ɳSelf
Supabase is a good starting point. At some stage the math changes: vendor lock-in, egress fees, or just the fact that you want Postgres running on hardware you own.
This post walks through a real migration. No cheerleading, no hand-waving. Just the actual steps.
What moves cleanly
The core primitives are the same on both sides.
- Postgres schema and data: standard
pg_dump/pg_restore - Auth JWT claims: both use standard JWT with configurable secrets
- S3-compatible storage: bucket exports and re-imports
- Row-level security policies: SQL is SQL
The Supabase-specific parts that need adjustment:
- Realtime channel names (Supabase uses its own subscription layer; ɳSelf uses Hasura subscriptions)
- Auth provider configuration (OAuth app credentials stay the same; the redirect URLs change)
- Edge Function code (replaced by ɳSelf custom services or plugins)
- Storage bucket policies (rewritten in MinIO notation)
None of these require data loss. They are one-time rewrites.
Step 1: Export your schema and data
From your Supabase project settings, get the connection string. Then:
pg_dump \
--schema-only \
--no-owner \
--no-acl \
-d "postgresql://postgres.[ref]:[password]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" \
-f schema.sql
pg_dump \
--data-only \
--no-owner \
-d "postgresql://postgres.[ref]:[password]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" \
-f data.sql
Strip the Supabase-internal schemas from the export. You only need public (and any custom schemas you created):
pg_dump \
--schema=public \
--no-owner \
--no-acl \
-d "postgresql://..." \
-f schema.sql
Supabase adds some extensions by default (uuid-ossp, pgcrypto, pgjwt). ɳSelf ships Postgres with these enabled, so those CREATE EXTENSION lines are safe to include or exclude.
Step 2: Start your ɳSelf instance
brew install nself-org/nself/nself
nself init
nself start
nself init writes .env.dev. It prompts for a domain (use localhost for local migration testing). nself start boots Postgres, Hasura, Auth, and Nginx. The whole stack is running in about 90 seconds on a $4 Hetzner VPS.
Check that Postgres is accepting connections:
nself db status
Step 3: Import schema and data
nself db psql < schema.sql
nself db psql < data.sql
nself db psql drops you into the internal Postgres shell with the right credentials. Pipe your SQL files directly.
If you hit extension errors:
-- Remove lines like this from schema.sql before importing:
-- CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA extensions;
-- ɳSelf already has these loaded.
Verify row counts match the Supabase export:
nself db psql -c "SELECT schemaname, tablename, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 20;"
Step 4: Run the Supabase migration helper
ɳSelf ships a built-in migration command for Supabase projects:
nself migrate supabase --connection-string "postgresql://postgres.[ref]:[password]@..."
This handles:
- Re-mapping Supabase's
auth.usersto ɳSelf'sauth.accountsformat - Converting Supabase RLS policies to Hasura row-level permissions
- Importing storage buckets via the MinIO client
The command is a best-effort helper. Review the output. Some policies need manual adjustment when they reference auth.uid() (Supabase) vs Hasura's X-Hasura-User-Id claim.
Step 5: Update auth configuration
Supabase Auth uses SUPABASE_JWT_SECRET. ɳSelf Auth uses HASURA_GRAPHQL_JWT_SECRET.
The token format is the same. The secret changes. Update it in .env.dev (or .env.prod for production):
# In .env.dev
HASURA_GRAPHQL_JWT_SECRET='{"type":"HS256","key":"your-new-secret-here"}'
For OAuth providers (GitHub, Google, Discord, etc.): the app credentials stay the same. Update the redirect URI in each OAuth app to point to your ɳSelf Auth callback:
https://yourdomain.com/api/auth/callback/github
ɳSelf Auth supports 13+ OAuth providers. Configuration is in the Admin UI at localhost:3021/auth.
Step 6: Swap realtime subscriptions
Supabase realtime uses its own client library:
// Supabase
const channel = supabase
.channel('my-channel')
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, handler)
.subscribe()
ɳSelf uses Hasura subscriptions via GraphQL:
// ɳSelf (Apollo Client or any GraphQL client)
const MESSAGES_SUB = gql`
subscription OnNewMessage {
messages(order_by: { created_at: desc }, limit: 50) {
id
body
created_at
}
}
`
If you need WebSocket-based realtime beyond GraphQL subscriptions, the realtime plugin adds a dedicated channel server:
nself plugin install realtime
nself build && nself restart
Step 7: Point your app to the new backend
Update your environment variables:
# Old (Supabase)
SUPABASE_URL=https://[ref].supabase.co
SUPABASE_ANON_KEY=eyJ...
# New (ɳSelf)
HASURA_GRAPHQL_URL=https://api.yourdomain.com/v1/graphql
NSELF_AUTH_URL=https://api.yourdomain.com/v1/auth
Replace @supabase/supabase-js calls with your GraphQL client of choice. Apollo, URQL, or raw fetch all work. ɳSelf does not require a vendor SDK.
What you gain
After the migration:
- One VPS bill instead of a Supabase subscription that scales with egress
- Full Postgres access without a proxy layer
- 87 plugins for AI, email routing, cron, search, live video, and more
- The Admin UI at
localhost:3021for schema browsing, plugin management, and log access
Cost comparison at 50k monthly active users:
| Supabase Pro | ɳSelf | |
|---|---|---|
| Compute | $25/mo | $4/mo (Hetzner CX23) |
| Egress | $0.09/GB above 250GB | $0 (unlimited) |
| Auth | Included | Included |
| Storage | $0.021/GB above 1GB | $0 (MinIO, self-hosted) |
| Estimated total | $60-150/mo | $4-8/mo |
The Supabase team does good work. The platform is polished and the DX is hard to beat for prototyping. When you need data ownership and predictable costs, self-hosting wins.
brew install nself-org/nself/nself
nself init
nself migrate supabase --connection-string "postgresql://..."
Your data. Your server. Your budget.
Get updates from the ɳSelf blog
Engineering posts, product updates, and technical guides. No spam.