One of the strongest arguments for self-hosting Supabase is that you get a real Postgres database with no gatekeeping — direct connections, superuser-adjacent roles, any tooling you want. So it's no surprise that many teams pair their self-hosted Supabase deployment with a TypeScript ORM like Prisma or Drizzle instead of (or alongside) the PostgREST-based supabase-js client.
It mostly works great. But there are three places where people reliably get burned: connection strings that behave differently through the pooler, migration tooling fighting with Supabase's own schemas, and Row Level Security silently not applying. This guide covers all three, with working configs for both ORMs.
Why ORMs and self-hosted Supabase fit together
On Supabase Cloud, direct database access is something you configure around IPv6 constraints and pooler endpoints. On a self-hosted instance, Postgres is just there — a container on your server listening on 5432. That changes the calculus:
- No API-layer ceiling. Complex joins, window functions, and batch writes go straight to Postgres instead of being squeezed through PostgREST query syntax.
- Type safety end-to-end. Drizzle's schema-as-code or Prisma's generated client gives you compile-time guarantees that generated database types only partially provide.
- You still keep the Supabase services. Auth, Storage, and Realtime keep working — they don't care what wrote the rows. The common production pattern is
supabase-jsfor auth and storage on the client, ORM for data access on the server.
The trade-off, and it's a real one: your ORM connects as a privileged database role, which means RLS does not protect those queries. More on that below — don't skip it.
Getting the connection string right
Self-hosted Supabase gives you two ways into Postgres, and picking the wrong one is the source of most "it works locally but dies in production" reports.
Direct connection (port 5432):
postgresql://postgres:your-password@your-server:5432/postgres
Through Supavisor in transaction mode (port 6543):
postgresql://postgres.your-tenant-id:your-password@your-server:6543/postgres
Note the username format on the pooled connection: postgres.your-tenant-id, where the tenant ID matches POOLER_TENANT_ID in your .env. Getting this wrong produces the infamous Tenant or user not found error that fills the self-hosting GitHub discussions. If you've never touched Supavisor's configuration, our connection pooling guide for self-hosted Supabase walks through the whole setup.
Which one should your ORM use?
| Scenario | Connection |
|---|---|
| Long-running Node server (Express, Nest, VPS-hosted Next.js) | Direct, port 5432 |
| Serverless / edge functions (Vercel, Lambda) | Supavisor transaction mode, port 6543 |
Migrations (prisma migrate, drizzle-kit) | Always direct, port 5432 |
Transaction-mode pooling breaks prepared statements, session variables, and advisory locks — all things migration tools rely on. Run migrations through the pooler and you'll get cryptic failures halfway through a DDL change, which is exactly when you don't want cryptic failures.
Prisma setup for self-hosted Supabase
Prisma needs two URLs: a runtime URL and a direct URL for migrations.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
schemas = ["public"]
}
# .env — serverless runtime through Supavisor DATABASE_URL="postgresql://postgres.your-tenant:pass@your-server:6543/postgres?pgbouncer=true&connection_limit=1" # Migrations go direct DIRECT_URL="postgresql://postgres:pass@your-server:5432/postgres"
Three details matter here:
?pgbouncer=truedisables Prisma's prepared statements, which transaction-mode pooling can't support. Without it you'll seeprepared statement "s0" already existserrors under load. (The flag predates Supavisor but works the same way.)schemas = ["public"]keeps Prisma's hands offauth,storage, andrealtime. If you runprisma db pullwithout scoping it, Prisma introspects Supabase's internal schemas, and a latermigrate devmay try to "fix" them. That's a fast route to a broken Auth service.- Connection limits. Prisma defaults to
num_cpus * 2 + 1connections per instance. On a modest VPS running the full Supabase stack, a few serverless instances can exhaust Postgres'smax_connectionson their own. Setconnection_limit=1for serverless and let Supavisor do the multiplexing.
If you reference auth.users from your tables (almost everyone does), declare the foreign key in SQL rather than in Prisma's schema, or model auth.users as an unmanaged table. Prisma shouldn't own anything in the auth schema.
Drizzle setup for self-hosted Supabase
Drizzle is lighter-touch — it speaks through a standard Postgres driver, so the configuration lives in the driver:
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
// Long-running server: direct connection
const client = postgres(process.env.DATABASE_URL!);
// Serverless through Supavisor: disable prepared statements
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
export const db = drizzle(client);
prepare: false is Drizzle's equivalent of Prisma's pgbouncer=true — required in transaction mode, harmless otherwise. One community pitfall worth naming: you do not need the Neon serverless driver to talk to self-hosted Supabase, despite what some old forum answers suggest. Plain postgres-js or node-postgres against your own server works fine and avoids an HTTP round-trip designed for someone else's infrastructure.
For drizzle-kit, point it at the direct connection and scope introspection:
export default defineConfig({
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: { url: process.env.DIRECT_URL! },
schemaFilter: ['public'],
});
schemaFilter: ['public'] does the same job as Prisma's schemas setting: it keeps drizzle-kit push from generating diffs against Supabase's internal schemas.
Decide who owns migrations — and pick one
This is the architectural decision that matters more than which ORM you choose. You now have two tools that both want to be the source of truth for your schema: your ORM's migration system and the Supabase CLI's SQL migrations. Running both against public ends in drift, failed deploys, and a schema nobody trusts.
The split that works in practice:
- Your ORM owns
public. Application tables, indexes, constraints — all defined in Prisma schema or Drizzle TypeScript, all migrated by the ORM's tooling. - Plain SQL owns everything Supabase-flavored. RLS policies, triggers on
auth.users, storage bucket policies, Postgres functions. Prisma can't express these at all; Drizzle covers some but not all. Keep them in versioned SQL files and apply them in the same CI step, after the ORM migration runs.
Whatever you choose, wire it into a pipeline rather than running migrations from laptops — our schema migrations guide covers ordering, rollback strategy, and CI integration in depth.
And take a backup before every migration deploy. A migration that half-applies against production is a genuinely bad afternoon; a pre-migration backup with one-click restore turns it into a five-minute rollback. This is one of the workflows Supascale automates for self-hosted instances — scheduled S3 backups plus an API you can call from CI right before migrate deploy.
The RLS blind spot
Here's the honest trade-off that ORM tutorials skip: your ORM almost certainly bypasses Row Level Security.
RLS policies apply to roles that don't hold the BYPASSRLS attribute or own the table. When supabase-js talks to PostgREST, queries run as the constrained authenticated role with the user's JWT claims applied. When Prisma or Drizzle connects as postgres, none of your policies run. Every query sees every row.
For a classic server-side app this can be acceptable — your API layer does authorization, and RLS was never in the loop. But if your security model assumes RLS (because the same tables are also exposed through PostgREST to browsers), you've now got two enforcement paths, and one of them is wide open. Options, in increasing order of effort:
- Accept it, deliberately. Document that server-side code is trusted and authorization happens in application code. Fine for internal tools; risky for multi-tenant apps.
- Create a dedicated non-bypassing role for your ORM, grant it table access, and set the user's JWT claims per-transaction (
set_config('request.jwt.claims', ...)) so your existing policies evaluate correctly. Drizzle ships helpers for exactly this pattern. - Keep user-scoped reads on
supabase-js, ORM for trusted writes. Blunt, but easy to reason about.
Whichever you pick, pick it explicitly. If you're fuzzy on how the policies themselves work, start with our RLS guide for self-hosted Supabase before layering an ORM on top.
Prisma or Drizzle?
Both are production-ready against self-hosted Supabase in 2026. The honest differentiators:
- Drizzle is the lighter fit for the Supabase ecosystem: SQL-like syntax, no generation step, native RLS policy helpers, and a much smaller cold-start footprint for edge runtimes.
- Prisma brings a more mature migration engine, a bigger ecosystem, and a schema DSL that non-TypeScript teammates can read. Its historical serverless weight problems are largely resolved since the Rust-free client engine landed.
If you're starting fresh on self-hosted Supabase, Drizzle's philosophy — stay close to Postgres — matches why you self-hosted in the first place. If your team already knows Prisma, the configs above make it behave; there's no need to migrate.
Wrapping up
An ORM against self-hosted Supabase is a strong combination: full Postgres power with type safety, while Auth, Storage, and Realtime keep doing their jobs. The rules that keep it boring: migrations always over the direct connection, prepared statements off in transaction mode, one owner for the public schema, internal Supabase schemas left alone, and an explicit decision about RLS rather than an accidental one. Get those five right and the stack stays quiet — which is the whole point.
