Claude
Skills
Sign in
Back

supabase-prod-checklist

Included with Lifetime
$97 forever

Execute Supabase production deployment checklist covering RLS, key hygiene, connection pooling, backups, monitoring, Edge Functions, and Storage policies. Use when deploying to production, preparing for launch, or auditing a live Supabase project for security and performance gaps. Trigger with "supabase production", "supabase go-live", "supabase launch checklist", "supabase prod ready", "deploy supabase", "supabase production readiness".

Cloud & DevOpssaassupabasedeploymentproductionsecurityrls

What this skill does

# Supabase Production Deployment Checklist

## Overview

Actionable 14-step checklist for taking a Supabase project to production. Covers RLS enforcement, key separation, connection pooling (Supavisor), backups/PITR, network restrictions, custom domains, auth emails, rate limits, monitoring, Edge Functions, Storage policies, indexes, and migrations. Based on Supabase's official [production guide](https://supabase.com/docs/guides/deployment/going-into-prod).

## Prerequisites

- Supabase project on Pro plan or higher (required for PITR, network restrictions)
- Separate production project (never share dev/prod)
- `@supabase/supabase-js` v2+ installed
- Supabase CLI installed (`npx supabase --version`)
- Domain and DNS configured for custom domain
- Deployment platform ready (Vercel, Netlify, Cloudflare, etc.)

## Instructions

### Step 1: Enforce Row Level Security on ALL Tables

RLS is the single most critical production requirement. Without it, any client with your anon key can read/write every row.

```sql
-- Audit: find tables WITHOUT RLS enabled
-- This query MUST return zero rows before going live
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;
```

```sql
-- Enable RLS on a table
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;

-- Create a basic read policy (authenticated users see own rows)
CREATE POLICY "Users can view own profile"
  ON public.profiles
  FOR SELECT
  USING (auth.uid() = user_id);

-- Create an insert policy
CREATE POLICY "Users can insert own profile"
  ON public.profiles
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Create an update policy
CREATE POLICY "Users can update own profile"
  ON public.profiles
  FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);
```

- [ ] RLS enabled on every public table (zero rows from audit query above)
- [ ] SELECT, INSERT, UPDATE, DELETE policies defined for each table
- [ ] Policies tested with both authenticated and anonymous roles
- [ ] No tables use `USING (true)` without intent (public read tables only)

### Step 2: Enforce Key Separation — Anon vs Service Role

The `anon` key is safe for client-side code. The `service_role` key bypasses RLS entirely and must never leave server-side environments.

```typescript
// Client-side — ONLY use anon key
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!  // Safe for browsers
);
```

```typescript
// Server-side only — service_role key (API routes, webhooks, cron jobs)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,  // NEVER expose to client
  { auth: { autoRefreshToken: false, persistSession: false } }
);
```

- [ ] Anon key used in all client-side code (`NEXT_PUBLIC_` prefix)
- [ ] Service role key used only in server-side code (API routes, Edge Functions)
- [ ] Service role key not in any client bundle (verify with `grep -r "service_role" dist/`)
- [ ] Database password changed from the auto-generated default

### Step 3: Configure Connection Pooling (Supavisor)

Supabase uses Supavisor for connection pooling. Serverless functions (Vercel, Netlify, Cloudflare Workers) MUST use the pooled connection string to avoid exhausting the database connection limit.

```
# Direct connection (migrations, admin tasks only)
postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres

# Pooled connection via Supavisor (application code — USE THIS)
# Port 6543 = Supavisor pooler (vs 5432 direct)
postgresql://postgres.[REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
```

```typescript
// For serverless environments — use pooled connection
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!,
  {
    db: { schema: 'public' },
    // Supavisor handles pooling at port 6543
    // No need to configure pgBouncer settings in the client
  }
);
```

- [ ] Application code uses pooled connection string (port 6543)
- [ ] Direct connection reserved for migrations and admin tasks only
- [ ] Connection string in deployment platform env vars (not hardcoded)
- [ ] Verified pool mode: `transaction` for serverless, `session` for long-lived connections

### Step 4: Enable Database Backups

Supabase provides automatic daily backups on Pro plan. Point-in-time recovery (PITR) enables granular restores.

- [ ] Automatic daily backups enabled (Pro plan — verify in Dashboard > Database > Backups)
- [ ] Point-in-time recovery configured (Dashboard > Database > Backups > PITR)
- [ ] Tested restore procedure on a staging project (do not skip this)
- [ ] Migration files committed to version control (`supabase/migrations/` directory)
- [ ] `npx supabase db push` tested against a fresh project to verify migrations replay cleanly

### Step 5: Configure Network Restrictions

Restrict database access to known IP addresses. This prevents unauthorized direct database connections even if credentials leak.

- [ ] IP allowlist configured (Dashboard > Database > Network Restrictions)
- [ ] Only deployment platform IPs and team office IPs are allowed
- [ ] Verified that application still connects after restrictions applied
- [ ] Documented which IPs are allowed and why

### Step 6: Configure Custom Domain

A custom domain replaces the default `*.supabase.co` URLs with your brand domain for API and auth endpoints.

- [ ] Custom domain configured (Dashboard > Settings > Custom Domains)
- [ ] DNS CNAME record added and verified
- [ ] SSL certificate provisioned and active
- [ ] Application code updated to use custom domain URL
- [ ] OAuth redirect URLs updated to use custom domain

### Step 7: Customize Auth Email Templates

Default Supabase auth emails show generic branding. Customize them so users see your domain and brand.

- [ ] Confirmation email template customized (Dashboard > Auth > Email Templates)
- [ ] Password reset email template customized
- [ ] Magic link email template customized
- [ ] Invite email template customized
- [ ] Custom SMTP configured (Dashboard > Auth > SMTP Settings) — avoids rate limits and improves deliverability
- [ ] Email confirmation enabled (Dashboard > Auth > Settings)
- [ ] OAuth redirect URLs restricted to production domains only
- [ ] Unused auth providers disabled

### Step 8: Understand Rate Limits Per Tier

Supabase enforces rate limits that vary by plan. Hitting these in production causes 429 errors.

| Resource | Free | Pro | Team |
|----------|------|-----|------|
| API requests | 500/min | 1,000/min | 5,000/min |
| Auth emails | 4/hour | 30/hour | 100/hour |
| Realtime connections | 200 concurrent | 500 concurrent | 2,000 concurrent |
| Edge Function invocations | 500K/month | 2M/month | 5M/month |
| Storage bandwidth | 2GB/month | 250GB/month | Custom |
| Database size | 500MB | 8GB | 50GB |

- [ ] Rate limits documented for your plan tier
- [ ] Client-side retry logic with exponential backoff for 429 responses
- [ ] Auth email rate limits understood (use custom SMTP to increase)
- [ ] Realtime connection limits planned for expected concurrent users

### Step 9: Review Monitoring Dashboards

Supabase provides built-in monitoring. Review these before launch to establish baselines.

```typescript
// Health check endpoint — deploy this to your application
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
);

export async function GET() {
  const start = Date.now();
  const { data, error } = await supabase
    .from('_health_check')  // Create a small table for this
    .select('id')
    .limit(1);

  const latency = Date.now() - start;

  return Response.json({
    status: error ? 'unhealthy' : 'healthy',
    latency_ms: latency,
    timestamp: new Date().toIS

Related in Cloud & DevOps