Drizzle ORM

Use Drizzle with SupaHost to define tables and RLS policies in TypeScript and generate SQL migrations. Its runtime needs direct Postgres, which SupaHost offers only via the Business SSH tunnel.

Drizzle lets you describe your schema in TypeScript and generates SQL migrations from it with drizzle-kit. Its query builder, like any Postgres ORM, needs a direct database connection.

On SupaHost, Postgres isn't reachable from the internet. Direct connections exist only on Business and Enterprise, through a restricted SSH tunnel opened from a machine you control. So:

You want to Plan How
Define tables and policies in TypeScript, generate SQL Any drizzle-kit generate, then apply the SQL to SupaHost
Iterate quickly with drizzle-kit push Any Against the Supabase CLI's local database
Run drizzle-kit migrate against SupaHost Business, Enterprise Through the SSH tunnel
Run Drizzle queries from your deployed app Not supported Use supabase-js (the Data API) in the app

Setup

npm install drizzle-orm postgres
npm install drizzle-kit --save-dev
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './supabase/migrations',
  migrations: { prefix: 'supabase' },
  entities: { roles: { provider: 'supabase' } },
  dbCredentials: { url: process.env.DATABASE_URL! },
})

Two options make Drizzle play well with the Supabase CLI:

  • migrations.prefix: 'supabase' names files with the timestamp format the CLI uses, so they sit in supabase/migrations next to hand-written ones.
  • entities.roles.provider: 'supabase' tells drizzle-kit that roles such as anon and authenticated already exist, so it doesn't try to create them.

Define a table with policies

Drizzle can declare Row Level Security policies next to the table. The helpers in drizzle-orm/supabase give you the standard roles and auth.uid():

// src/db/schema.ts
import { sql } from 'drizzle-orm'
import { bigint, pgPolicy, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { authenticatedRole, authUid } from 'drizzle-orm/supabase'

export const messages = pgTable(
  'messages',
  {
    id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
    channelId: bigint('channel_id', { mode: 'number' }).notNull(),
    authorId: uuid('author_id').notNull().default(sql`auth.uid()`),
    body: text('body').notNull(),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    pgPolicy('members read messages', {
      for: 'select',
      to: authenticatedRole,
      using: sql`true`,
    }),
    pgPolicy('authors post as themselves', {
      for: 'insert',
      to: authenticatedRole,
      withCheck: sql`${authUid} = ${t.authorId}`,
    }),
  ]
)

Declaring a policy turns RLS on for the table. In a real team chat, the select policy would check channel membership instead of true.

Develop against the local stack

Start the Supabase CLI stack (npx supabase start, see Local development) and use its database:

# .env
DATABASE_URL=postgresql://postgres:[email protected]:54322/postgres

While you're experimenting, npx drizzle-kit push applies the schema directly. When the shape is right, generate a migration and rebuild from scratch to check it:

npx drizzle-kit generate --name add_messages
npx supabase db reset

Read the generated SQL. You can add anything Drizzle doesn't model, such as grants, triggers or functions, to the same file before committing it.

Apply migrations to SupaHost

Any plan: apply the generated file with MCP apply_migration or paste it into Studio's SQL editor. Details are in Migrations workflow.

Business and Enterprise: open the SSH tunnel and let drizzle-kit apply what's pending:

ssh -N -L 5432:127.0.0.1:5432 [email protected]
DATABASE_URL='postgresql://postgres:<password>@127.0.0.1:5432/p_app_one' \
  npx drizzle-kit migrate

The connection string comes from Connect → SSH & CLI → Show connection string. drizzle-kit migrate keeps its own history table (drizzle.__drizzle_migrations), separate from the supabase_migrations table that MCP uses. Use one method per project so the two don't disagree.

To start from an existing database, npx drizzle-kit pull over the tunnel (or against a local copy) writes a TypeScript schema from your current tables.

Querying in your app

Your deployed app can't use the tunnel, so query through the Data API with supabase-js. Requests run as the signed-in user, so the policies above apply:

const { data, error } = await supabase
  .from('messages')
  .select('id, body, author_id, created_at')
  .eq('channel_id', channelId)
  .order('created_at')

For types, generate them from the database with npx supabase gen types typescript locally or MCP generate_typescript_types, and pass them to createClient<Database>(). See Generating TypeScript types.

Drizzle queries are still useful for scripts you run over the tunnel on Business:

import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { messages } from './db/schema'

const client = postgres(process.env.DATABASE_URL!)
const db = drizzle(client)

const recent = await db.select().from(messages).limit(20)
await client.end()

These connect as postgres, which owns the tables, so RLS policies don't restrict them.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us