Production-grade Supabase + Postgres discipline: RLS policies done right, migration workflow via the CLI, edge function hygiene, and backup/recovery basics. Triggers when an agent sets up a Supabase database, writes policies, changes schema, deploys edge functions, or reviews a Supabase project's health.
./install.sh supabase-postgres-patternsname: "supabase-postgres-patterns" description: "Production-grade Supabase + Postgres discipline: RLS policies done right, migration workflow via the CLI, edge function hygiene, and backup/recovery basics. Triggers when an agent sets up a Supabase database, writes policies, changes schema, deploys edge functions, or reviews a Supabase project's health."
Supabase is Postgres with batteries. Most projects fail not on Postgres itself but on the Supabase-specific surface: tables shipped without RLS, schema edited in the dashboard with no migration trail, secrets in client bundles, service_role keyed functions written where RLS would do, and no backup story until something breaks. This skill is the production checklist and the correct commands for each of those.
RLS on, explicit policies, explicit grants. Never rely on client-side filtering.
sql
-- New table, full lockdown pattern
create table todos (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id),
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
alter table todos enable row level security;
-- Opt-in grants (new projects since 2026-05-30 require explicit GRANTs;
-- enforced on all projects 2026-10-30). Old default: new public tables were
-- auto-granted to anon/authenticated. Don't rely on the old behavior.
alter default privileges for role postgres in schema public
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
grant select, insert, update, delete on todos to authenticated;
One policy per operation. SELECT gets using, INSERT gets with check, UPDATE gets both (using gates the existing row, with check gates the resulting row so a user cannot reassign user_id to someone else), DELETE gets using. Never use for all just to save lines; it hides which rule covers which operation.
sql
create policy "Users see their own todos"
on todos for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users create their own todos"
on todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users update their own todos"
on todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Users delete their own todos"
on todos for delete
to authenticated
using ( (select auth.uid()) = user_id );
The (select auth.uid()) wrapper matters: it lets the planner cache the value once per statement instead of evaluating per row. Scope every policy to authenticated (or the right role) so anon callers short-circuit before the predicate runs. Index every column a policy predicate filters (create index on todos (user_id);); policy predicates are query predicates. auth.uid() is null when unauthenticated, so policies fail closed.
Verify with a query, not a feeling. See references/rls-patterns.md.
The dashboard SQL editor is an emergency fallback, not a workflow. Migrations are the source of truth.
bash
supabase login
supabase link --project-ref <project-ref>
supabase migration list # local vs remote state, always first
supabase migration new add_due_date_to_todos
# write the SQL in supabase/migrations/<timestamp>_add_due_date_to_todos.sql
supabase db push --dry-run # preview exactly what will run
supabase db push # apply pending migrations
supabase migration list # confirm local and remote agree
supabase db lint --linked # lint the linked remote schema
Rules: one migration does one thing (a column, an index, a backfill, never all three). Migrations are append-only and transactional; never edit or rename an applied migration unless you are repairing drift, and then rename the file to match the remote version exactly. Name migrations by what they do (add_due_date_to_todos), not migration_42_final. Destructive changes (drop column, change type, not null without a default) are breaking changes for old clients; they go in a planned multi-step sequence, never a routine push. If you must capture dashboard changes, supabase db diff -f <name> turns them into a file; supabase db reset replays everything locally plus supabase/seed.sql.
Edge functions run Deno server-side. They are the only place for third-party keys and service_role work. The client gets the publishable/anon key (RLS-gated); it never sees service_role.
Available automatically in every function: SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY (bypasses RLS, server only), SUPABASE_DB_URL. Set your own via supabase secrets set NAME=value (values land immediately, no redeploy; limit 100 secrets, names cannot start with SUPABASE_). Locally: supabase/functions/.env is auto-loaded by supabase start, or supabase functions serve --env-file .env.local.
Two client patterns inside a function:
typescript
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
// Acts AS the caller: RLS still applies. Default path for reads/writes.
const userClient = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization")! } } },
);
// Acts AS ADMIN: bypasses RLS. Only after you verify the caller yourself.
const adminClient = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const { data: { user } } = await userClient.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
// ... privileged work with adminClient here ...
return Response.json({ ok: true });
});
Defaults: verify_jwt = true means the platform already validated the caller's JWT; set verify_jwt = false in config.toml (or --no-verify-jwt at deploy) only for public endpoints like webhooks, and then verify the webhook signature yourself. Handle the OPTIONS preflight for browser calls; the full CORS template lives in references/edge-function-template.md.
Paid plans: enable Point-in-Time Recovery in Project Settings -> Database -> Backups. Pro retains 7 days, Team 14, Enterprise up to 30. PITR restores spin up a NEW instance (the original is untouched; compare before you cut over), then update connection strings. RPO is seconds thanks to continuous WAL archiving.
Free tier or belt-and-suspenders: logical dumps.
bash
pg_dump "$DATABASE_URL" > backup_$(date +%Y%m%d_%H%M%S).sql
pg_dump --schema-only "$DATABASE_URL" > schema_$(date +%Y%m%d).sql
pg_dump --data-only "$DATABASE_URL" > data_$(date +%Y%m%d).sql
pg_dump "$DATABASE_URL" --exclude-table-data=big_log_table > lean.sql
Take a manual dump before every risky migration, name it with the migration, and confirm a restore once by loading it into a scratch database. An untested backup is a rumor. See references/backup-runbook.md.
(select auth.uid()), role-scoped, opt-in grants, indexed policy columns; all schema changes as CLI migrations, none via dashboard; secrets only in edge functions; backup plan documented (PITR enabled or dump cadence in place).with check, service_role usage reachable from client code, dashboard-made schema drift (migration list disagrees), and missing backup config. Each gap gets the exact fix.enable row level security plus policies first. A table with RLS on and zero policies denies everything; that is the safe default, not a bug.using filters existing rows, with check validates proposed rows. If you are unsure which one a write path needs, write both.security definer Postgres function with a locked-down search_path.supabase/migrations/ with a descriptive name. db push --dry-run before every push.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.