TechLead
Lesson 22 of 22
5 min read
Supabase

Supabase Production Checklist

Complete production checklist for Supabase: security audit, performance tuning, monitoring, backups, and cost optimization

Production Checklist

Going to production with Supabase requires careful attention to security, performance, monitoring, and operations. This checklist covers everything you need to review before launching, and the ongoing practices that keep your application reliable and secure.

🚀 Checklist Categories

  • Security: RLS, API keys, auth configuration
  • Performance: Indexes, connection pooling, query optimization
  • Monitoring: Logs, alerts, query statistics
  • Operations: Backups, environments, cost management

Security Audit

-- 1. Verify RLS is enabled on EVERY table
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
-- rowsecurity should be TRUE for every table!

-- 2. Check for tables without any RLS policies
SELECT t.tablename
FROM pg_tables t
LEFT JOIN pg_policies p ON t.tablename = p.tablename
WHERE t.schemaname = 'public'
  AND t.rowsecurity = true
GROUP BY t.tablename
HAVING COUNT(p.policyname) = 0;
-- These tables have RLS enabled but no policies (blocks all access!)

-- 3. Review all existing policies
SELECT tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename;

-- 4. Ensure no sensitive data is exposed
-- Check that service_role key is NEVER in client code
-- Check that API keys are in environment variables
-- Check that .env files are in .gitignore

Performance Checklist

-- 1. Find missing indexes (tables with sequential scans)
SELECT
  schemaname, relname AS table_name,
  seq_scan, idx_scan,
  seq_scan - idx_scan AS too_many_seqscans,
  n_live_tup AS row_count
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
  AND n_live_tup > 10000
ORDER BY too_many_seqscans DESC;

-- 2. Find unused indexes (wasting write performance)
SELECT
  indexrelname AS index_name,
  relname AS table_name,
  idx_scan AS times_used,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE '%_pkey'
ORDER BY pg_relation_size(indexrelid) DESC;

-- 3. Find slow queries
SELECT
  query,
  calls,
  mean_exec_time::numeric(10,2) AS avg_ms,
  max_exec_time::numeric(10,2) AS max_ms
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

-- 4. Check table bloat
SELECT
  relname AS table_name,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  n_dead_tup AS dead_rows,
  last_vacuum,
  last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

Application Security Checklist

// Security configuration checklist

// 1. Use getUser() on the server, NOT getSession()
// getUser() validates the JWT, getSession() does not
const { data: { user } } = await supabase.auth.getUser()

// 2. Validate and sanitize all user inputs
// Never trust client-side data in server actions
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  if (!title || title.length > 200) {
    throw new Error('Invalid title')
  }

  // 3. Always check auth before mutations
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  // 4. Let RLS handle authorization — don't rely on app logic alone
  const { error } = await supabase.from('posts').insert({
    title: title.trim(),
    user_id: user.id,  // RLS will verify this matches auth.uid()
  })
}

// 5. Configure auth settings in Supabase Dashboard:
// - Enable email confirmation
// - Set password minimum length (8+)
// - Configure rate limiting
// - Restrict OAuth redirect URLs
// - Enable CAPTCHA for sign-ups if needed

Monitoring & Alerting

-- Set up monitoring queries (run periodically with pg_cron)

-- Monitor connection count
SELECT count(*) AS active_connections,
  max_conn AS max_connections,
  count(*)::float / max_conn::float * 100 AS utilization_pct
FROM pg_stat_activity
CROSS JOIN (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') s
WHERE state IS NOT NULL;

-- Monitor database size growth
SELECT
  pg_size_pretty(pg_database_size('postgres')) AS db_size;

-- Monitor RLS policy performance
-- If policies use functions, ensure they're STABLE or IMMUTABLE
-- Avoid subqueries in policies — use security definer functions instead

Environment Management

# Use separate Supabase projects for each environment

# Development: local Supabase
npx supabase start

# Staging: separate Supabase project
npx supabase link --project-ref staging-project-id
npx supabase db push

# Production: production Supabase project
npx supabase link --project-ref production-project-id
npx supabase db push

# Environment variable management
# .env.local          → local development
# .env.staging        → staging
# .env.production     → production
# NEVER commit .env files to git

Cost Optimization

Cost Reduction Strategies

  • Reduce database size: Archive old data, use appropriate data types (text vs varchar), clean up unused tables
  • Optimize Storage: Compress images before upload, set lifecycle policies to delete temporary files, use image transformations instead of storing multiple sizes
  • Reduce bandwidth: Select only needed columns (.select('id, title') not .select('*')), implement pagination, cache responses
  • Realtime efficiency: Only subscribe to channels you need, unsubscribe when not needed, use filters to reduce events
  • Auth optimization: Clean up unconfirmed users periodically, use appropriate session lengths

💡 Key Takeaways

  • • Enable RLS on every table and verify with SQL queries
  • • Add indexes for queries filtering more than 10,000 rows
  • • Use getUser() not getSession() for server-side auth checks
  • • Monitor slow queries with pg_stat_statements
  • • Use separate Supabase projects for staging and production
  • • Select only needed columns and implement pagination to reduce costs

📚 Learn More

Continue Learning