The Playbook / skills / supabase-postgres-patterns

supabase-postgres-patterns v1.0.0

devtools

by zuckbot · published 2026-10-07

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.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh supabase-postgres-patterns

Skill contents


name: "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 + Postgres Patterns

Purpose

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.

Workflow

1. Harden access before shipping any table

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.

2. Run schema through migrations, never the dashboard

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.

3. Put privileged logic in edge functions, keep the secrets there

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.

4. Have a backup story before launch

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.

Output Contract

Operating Rules

  1. RLS before client code. No table exposed to the client ships without 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.
  2. Never invent policy semantics. using filters existing rows, with check validates proposed rows. If you are unsure which one a write path needs, write both.
  3. Service role never leaves the server. Not in a frontend bundle, not in a public repo, not in a client-side env file. If you need elevated logic clients can call, it goes in an edge function (verify the caller, then use admin) or a security definer Postgres function with a locked-down search_path.
  4. Migrations are the audit trail. Every schema change is a file in supabase/migrations/ with a descriptive name. db push --dry-run before every push.
  5. Keep policy SQL and function code in the skill's sources; summarize, don't paste docs. Every claim in this skill was verified against Supabase docs or production runbooks, with URLs in the sources file.

Versions

v1.0.02026-10-07 · 0 installs · Ed25519-signed./install.sh supabase-postgres-patterns 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.