Skip to main content
← Blog
2026-05-07 · ɳSelf Team

Switching from Supabase to ɳSelf

A practical migration guide: schema export, auth handoff, and the one CLI command that replaces three Supabase dashboards.

migration supabase tutorial

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.users to ɳSelf’s auth.accounts format
  • 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:3021 for 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)
AuthIncludedIncluded
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.