Using Prisma and Drizzle with Self-Hosted Supabase

Connect Prisma or Drizzle to self-hosted Supabase: correct connection strings, pooling gotchas, migration ownership, and honest RLS trade-offs explained.

Cover Image for Using Prisma and Drizzle with Self-Hosted Supabase

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-js for 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?

ScenarioConnection
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:

  1. ?pgbouncer=true disables Prisma's prepared statements, which transaction-mode pooling can't support. Without it you'll see prepared statement "s0" already exists errors under load. (The flag predates Supavisor but works the same way.)
  2. schemas = ["public"] keeps Prisma's hands off auth, storage, and realtime. If you run prisma db pull without scoping it, Prisma introspects Supabase's internal schemas, and a later migrate dev may try to "fix" them. That's a fast route to a broken Auth service.
  3. Connection limits. Prisma defaults to num_cpus * 2 + 1 connections per instance. On a modest VPS running the full Supabase stack, a few serverless instances can exhaust Postgres's max_connections on their own. Set connection_limit=1 for 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:

  1. 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.
  2. 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.
  3. 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.

Further Reading