Supabase in Production: Performance, Security, Backups, and Scaling
How to run Supabase in production: performance tuning, security with RLS, backup strategies, and scaling for real-world applications.

Introduction
Supabase is the easiest way to build apps, fast. Build in one place with a Postgres database, authentication, file storage and auto-generated APIs. But running Supabase in production is a whole different story.
What works on your laptop or free-tier project can break when real users start hitting your app. The most common problems teams face when they go live are slow queries, security holes, missing backups, and connection limits.
This guide shows you how to confidently run Supabase in production, with real-world scenarios for performance, security, backups, scaling, and more.
Quick Answer
TL;DR: To run Supabase in production, you need to: enable Row Level Security (RLS) on every table, add proper database indexes, use the connection pooler instead of direct connections, set up automated backups, and monitor your database performance. Skipping any of these steps can lead to security vulnerabilities, slow queries, or downtime.
What Is Supabase?
Supabase is an open-source backend platform built on top of PostgreSQL. It gives developers a ready-made backend with:
- Database — PostgreSQL with a visual editor
- Authentication — Email, OAuth, magic links, and more
- Storage — File uploads with access control
- Realtime — Live updates via WebSockets
- Edge Functions — Serverless functions built on Deno
- Auto-generated APIs — REST and GraphQL from your database schema
You can use it via Supabase Cloud (managed) or self-host it on your own servers.
Who should use it?Startups, indie developers, and product teams who want a full backend without managing complex infrastructure from scratch.
The Problem
Many teams deploy Supabase without proper production configuration. Here is what typically goes wrong:
- No RLS → Any user can read or edit any row in the database
- No indexes → Queries get slower as data grows
- Direct DB connections → App crashes when connection limit is hit
- No backups → One accidental DELETE and the data is gone
- No monitoring → You find out about problems from angry users, not alerts
These are not edge cases. They happen regularly to teams that move fast without a production checklist.
The Solution
A production-ready Supabase setup covers four areas:
Your Application ↓ Supabase API (Kong Gateway) ↓ PgBouncer (Connection Pooler) ↓ PostgreSQL Database ↓ RLS Policies + Indexes + Backups
Let's go through each area step by step.
Prerequisites
Before following this guide, you need:
- A Supabase project (Cloud or self-hosted)
- Basic knowledge of SQL and PostgreSQL
- Node.js if using the JavaScript client
- Access to Supabase Dashboard settings
Step-by-Step Implementation
Step 1: Enable Row Level Security on Every Table
Row Level Security (RLS) controls which users can read or write which rows. Without it, any authenticated user can access all your data through the API.
Enable RLS first:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
Then add policies. Here are the most common patterns:
Pattern 1 — Users can only see their own data:
CREATE POLICY "users_see_own_orders" ON orders FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
Pattern 2 — Public read, authenticated write:
CREATE POLICY "anyone_can_read_posts" ON posts FOR SELECT USING (published = true); CREATE POLICY "authors_can_write" ON posts FOR INSERT, UPDATE, DELETE USING (auth.uid() = author_id);
Pattern 3 — Admin role gets full access:
CREATE POLICY "admins_read_all" ON orders FOR SELECT USING ( (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' );
Important: Test your policies by switching roles in SQL editor before going live.
-- Test as a logged-in user SET LOCAL role TO authenticated; SET LOCAL request.jwt.claims TO '{"sub": "your-user-uuid", "role": "authenticated"}'; SELECT * FROM orders; -- Should only return this user's rows
Step 2: Add Database Indexes
Indexes make queries fast. Without them, PostgreSQL reads every row in a table for every query, which works fine at 1,000 rows but kills performance at 100,000+.
Find tables that need indexes:
SELECT tablename, seq_scan, n_live_tup AS total_rows FROM pg_stat_user_tables WHERE seq_scan > 0 AND n_live_tup > 10000 ORDER BY seq_tup_read DESC LIMIT 10;
Add indexes for common query patterns:
-- Single column (most common) CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id); -- Sort by date CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at DESC); -- Multiple columns in WHERE clause CREATE INDEX CONCURRENTLY idx_orders_user_status ON orders(user_id, status, created_at DESC); -- Only index a subset of rows (smaller and faster) CREATE INDEX CONCURRENTLY idx_orders_pending ON orders(created_at DESC) WHERE status = 'pending';
Tip: Always use CONCURRENTLY when creating indexes in production. It takes a bit longer, but it does not lock your table.
Step 3: Use the Connection Pooler
PostgreSQL can handle a limited number of direct connections (usually 100–200). Serverless apps, Edge Functions, and ORMs open many connections at once and can easily hit this limit.
The fix: Use Supabase's built-in connection pooler (PgBouncer).
In your Supabase Dashboard → Project Settings → Database, you will find two connection strings:
# Direct connection (avoid this in serverless/Edge Functions) postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres # Pooler - Transaction mode (use this for serverless) postgresql://postgres.[ref]:[password]@aws-0-us-east-1.pooler.supabase.com:6543/postgres # Pooler - Session mode (use this for ORMs like Prisma) postgresql://postgres.[ref]:[password]@aws-0-us-east-1.pooler.supabase.com:5432/postgresIf you use Prisma, add this to your connection URL:
DATABASE_URL="postgresql://...?pgbouncer=true&connection_limit=1"
Step 4: Set Up Backups
Supabase Cloud includes automatic daily backups. But the type depends on your plan:
| Plan | Backup | Retention | Point-in-Time Recovery |
|---|---|---|---|
| Free | Daily | 7 days | No |
| Pro | Daily | 7 days | Add-on |
| Team | Daily | 14 days | Included |
Do not rely only on automatic backups. Set up your own as well:
#!/bin/bash # Run this as a daily cron job TIMESTAMP=$(date +%Y%m%d_%H%M%S) DB_URL="postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres" # Create backup pg_dump --format=custom "$DB_URL" | gzip > "backup_${TIMESTAMP}.sql.gz" # Upload to S3 aws s3 cp "backup_${TIMESTAMP}.sql.gz" "s3://your-bucket/backups/" # Delete local file rm "backup_${TIMESTAMP}.sql.gz" echo "Backup done: backup_${TIMESTAMP}.sql.gz"To restore a backup:
pg_restore \ --no-owner \ --clean \ -d "$TARGET_DB_URL" \ "backup_20240101_000000.sql.gz"
Pro tip: Test your restore process on a staging project before you actually need it.
Step 5: Never Expose the Service Role Key
Supabase gives you two main keys:
| Key | What it does | Where to use it |
|---|---|---|
| anon key | Respects RLS (safe for clients) | Frontend, mobile apps |
| service_role key | Bypasses RLS entirely | Server-side only |
If you accidentally expose the service_role key in your frontend code, anyone can read or delete all your data regardless of your RLS policies.
// WRONG — never put service_role key in frontend const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY) // CORRECT — use anon key on the client const supabase = createClient(SUPABASE_URL, ANON_KEY) // Service role key goes in Edge Functions or server-side only // Edge Function example: const adminClient = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! )
Step 6: Optimize Queries
Avoid N+1 queries. This is the most common performance issue in Supabase apps.
// BAD — this runs 1 + N queries (one per order) const { data: orders } = await supabase.from('orders').select('') for (const order of orders) { const { data: user } = await supabase .from('users').select('').eq('id', order.user_id) } // GOOD — single query using embedding (PostgREST JOIN) const { data } = await supabase .from('orders') .select( id, status, total, created_at, users ( id, email, full_name ) ) .eq('status', 'pending') .order('created_at', { ascending: false }) .limit(50)
Move heavy logic to database functions:
-- Create the function in Supabase SQL editor CREATE OR REPLACE FUNCTION get_user_stats(p_user_id UUID) RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN RETURN ( SELECT json_build_object( 'total_orders', COUNT(*), 'total_spent', COALESCE(SUM(total), 0) ) FROM orders WHERE user_id = p_user_id AND status = 'completed' ); END; $$;
// Call it with a single round-trip const { data } = await supabase.rpc('get_user_stats', { p_user_id: userId })
Step 7: Add Caching for Repeated Queries
Some data, such as app settings, category lists, or pricing, doesn’t change very often. Cache them instead of hitting the database every time.
import { Redis } from '@upstash/redis' const redis = new Redis({ url: process.env.UPSTASH_REDIS_URL!, token: process.env.UPSTASH_REDIS_TOKEN! }) async function getCached<T>( key: string, fetchFn: () => Promise<T>, ttlSeconds = 300 ): Promise<T> { const cached = await redis.get<T>(key) if (cached) return cached const fresh = await fetchFn() await redis.setex(key, ttlSeconds, JSON.stringify(fresh)) return fresh } // Usage const categories = await getCached( 'categories:all', () => supabase.from('categories').select('*'), 600 // cache for 10 minutes )
Common Problems and Errors
1. "Too many connections" error
Cause: Your app is using direct database connections instead of the pooler.Fix: Switch to the pooler connection string (port 6543 for transaction mode).
2. RLS is blocking all queries
Cause: You enabled RLS but forgot to create policies.Fix: Create the right policy for your use case, or temporarily disable RLS while debugging:
-- Check existing policies SELECT * FROM pg_policies WHERE tablename = 'orders'; -- If you need to test without RLS SET LOCAL role TO service_role;
3. Queries getting slower over time
Cause: Table is growing but there are no indexes.Fix: Run the index check query from Step 2, then add indexes with CONCURRENTLY.
4. Realtime subscription not working
Cause: RLS is blocking the real time channel, or the filter is wrong.Fix: Make sure your realtime subscription filter matches your RLS policy:
// Make sure the filter matches what RLS allows const channel = supabase .channel(orders:${userId}) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'orders', filter: user_id=eq.${userId} // Must match RLS policy }, handler) .subscribe()
5. Edge Function is slow on first request
Cause: Cold start - the function container has to spin up.Fix: Move one-time setup outside the request handler so it only runs once per container:
// This runs ONCE when the container starts (not per request) const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) // This runs for every request Deno.serve(async (req) => { // Use the already-initialized client })
Best Practices
- Enable RLS on every table — Never leave a table open without security policies
- Always use CONCURRENTLY for indexes — Avoids locking tables in production
- Use the pooler endpoint — Especially in serverless and Edge Functions
- Keep the service role key server-side only — Treat it like a database root password
- Test your backup restore — A backup you have never restored is not a real backup
- Filter Real Time subscriptions — Wildcard subscriptions on large tables will hurt performance
- Use environment variables for all secrets — Never hardcode keys or passwords
Performance and Security Checklist
Performance
- Indexes added on FK columns and common WHERE columns
- Connection pooler in use (not direct connections)
- N+1 queries replaced with embedded selects
- Frequently read data cache with Redis or similar
- Heavy logic moved to PostgreSQL functions
Security
- RLS enabled on all tables
- Policies tested with role switching in SQL editor
- service_role key only used in server-side code
- All secrets stored in environment variables
- CORS restricted to production domains only
When Should You Use Supabase in Production?
Supabase is a great production choice when:
- You are building on PostgreSQL
- You want managed auth, storage, and APIs without building from scratch
- Your team knows SQL and relational databases
- You want to move fast without managing your own infrastructure
Consider alternatives when:
- You need a highly specialized database (e.g., graph or time-series only)
- Your compliance requirements need dedicated infrastructure
- You need a NoSQL schema with frequent structural changes
FAQ
Is Supabase good for production?
Yes. Supabase is used in production by many teams. The key is to configure it correctly. Enable RLS, use connection pooling, add proper indexes, and set up backups. Without these steps, any backend can have problems.
Does Supabase scale?
Yes. Supabase Cloud lets you upgrade your compute with no downtime, and it supports read replicas, connection pooling, and Edge Functions deployed globally. For very large apps, you can also self-host Supabase on your own infrastructure.
What happens if I forget to enable RLS?
Without RLS, any authenticated user can read or write any row in your table through the Supabase API. This is a serious security risk. Always enable RLS and create policies before you expose a table to your frontend.
How often does Supabase back up my database?
Supabase Cloud runs daily backups automatically. Retention depends on your plan (7 days on Free and Pro, 14 days on Team). For Point-in-Time Recovery (PITR), you need the Pro plan add-on or the Team plan.
Can Supabase handle high traffic?
Yes, with the right setup. Use the connection pooler to handle many concurrent connections, add indexes to keep queries fast as your data grows, and consider caching frequently read data with Redis to reduce database load.
Is Supabase secure for storing sensitive user data?
Yes, if configured properly. Use RLS to control access, store secrets in environment variables, enable column-level encryption for very sensitive fields (like SSNs or payment data), and never expose the service_role key to the client.
Conclusion
Supabase is a powerful backend platform that can absolutely handle production workloads. The difference between a smooth production deployment and a painful one comes down to a few key steps: enable RLS on every table, use indexes, connect through the pooler, and set up backups you have actually tested.
None of these are hard to do, but they are easy to skip when you are in a hurry to ship. Use the checklist in this guide before you go live, and you will avoid the most common Supabase production problems.

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


