Setting up separate development and production environments does not have to be painful. This guide shows you how to build a professional deployment workflow for your Supabase project. You will learn the essential patterns that prevent the 3am "I dropped production" panic attacks while keeping your workflow fun, simple, and safe.
Supabase is the open source Postgres development platform. At its core, it is just Postgres, but with an integrated suite: Auth, Storage, Edge Functions, Realtime, and Vector search. That means you can start hacking in minutes and also scale to millions when your app takes off.
With this post, we'll explore how to setup a professional development and staging environment for our projects to prevent those late night panics.
Rule #1: never work directly on production#
The fastest way to ruin your night is to treat your one Supabase project as both your playground and your live app. One wrong DROP TABLE and your users are gone. The simple fix is to create at least two projects: one for breaking things (development) and one for your users (production). Larger teams often add a staging project as well, but the minimum is two.
Create them in the Supabase Dashboard and give them obvious names like myapp-dev and myapp-prod. Boring names reduce mistakes. Grab the Project Reference IDs from Settings > General and stash them in a safe place.
Then set up the Supabase CLI:
_10npm install supabase --save-dev_10supabase init
This creates a supabase/ directory, your single source of truth for migrations, functions, and seed data. Treat it like a ledger of every database change. Because it is just files, you can track it with Git, roll changes forward, and keep environments in sync. The flow should always be one direction: local development → dev project → production project. That is how you avoid the pain of trying to sync in multiple directions later.
Database migrations are git commits for your database#
Migrations are your safety net. Each one is a timestamped SQL file in supabase/migrations/. They record what changed and when, just like Git commits. This is how you avoid schema drift, where dev and prod quietly diverge until one day you cannot deploy without breaking things.
Here is the basic workflow:
-
Create a migration whenever you need to change the schema:
_10supabase migration new add_user_profiles -
Fill in the SQL, and always enable Row Level Security. Without RLS, anyone with your project URL can read all your data.
_10CREATE TABLE public.profiles (_10id UUID REFERENCES auth.users ON DELETE CASCADE,_10username TEXT UNIQUE,_10avatar_url TEXT,_10created_at TIMESTAMPTZ DEFAULT NOW()_10);_10ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;_10CREATE POLICY "Users can view own profile" ON profiles_10FOR SELECT USING (auth.uid() = id); -
Test locally before touching any remote environment:
_10supabase db reset -
If you make changes in the Dashboard instead of SQL, capture them:
_10supabase db diff -f capture_dashboard_changes
Because migrations must run in order on a fresh database, always reset locally to prove they work. If supabase db reset works, production will too. This habit prevents the subtle drift that causes late night panics.
Common pitfalls and how to avoid them#
Every developer hits the same landmines once. Knowing them up front means you only hit them once.
- Forgetting to enable RLS. Without it, your tables are wide open. Always add
ALTER TABLE ... ENABLE ROW LEVEL SECURITY;. - Deploying to the wrong environment. Give production a different terminal theme, never store its credentials locally, and run
supabase statusto check before you push. - Migration conflicts. If two migrations collide after a Git merge, rename one with a later timestamp and rerun
supabase db resetto verify the order. - Exposed service role keys. If one leaks, rotate it immediately in the Dashboard, update every environment, and scrub your Git history.
Mistakes are inevitable. Guardrails keep them from becoming disasters.
GitHub autopilot with CI/CD#
Manual deploys are risky. Automating them with GitHub Actions removes the human error. The idea is simple: push to develop to deploy to staging, merge to main to deploy to production.
Add your secrets in Settings > Secrets and variables > Actions. Then create .github/workflows/deploy.yml:
_21name: Deploy Supabase_21on:_21 push:_21 branches: [main, develop]_21jobs:_21 deploy:_21 runs-on: ubuntu-latest_21 steps:_21 - uses: actions/checkout@v4_21 - uses: supabase/setup-cli@v1_21 with: { version: latest }_21 - name: Deploy to staging_21 if: github.ref == 'refs/heads/develop'_21 run: |_21 supabase link --project-ref ${{ secrets.STAGING_PROJECT_ID }}_21 supabase db push_21 - name: Deploy to production_21 if: github.ref == 'refs/heads/main'_21 run: |_21 supabase link --project-ref ${{ secrets.PRODUCTION_PROJECT_ID }}_21 supabase db push
Now deployments happen automatically with every push. You do not have to remember commands or worry about sending them to the wrong project. Larger teams often extend this with integration tests that run against staging before code can be promoted, but even this simple setup eliminates most accidents.
Backups are your safety net#
Every production app needs a backup plan. The best plans run without you thinking about them. Set up a GitHub Action to dump your database nightly.
Even better, enable Point in Time Recovery (PITR) in the Supabase Dashboard. It lets you roll back to any point in time, not just to last night's snapshot. If your weekend project has suddenly taken off, you'll want to invest the money in keeping your environment solid.
Backups are only useful if you know they work. Schedule a monthly drill: restore a backup to a new project, run through your app, and confirm the data is intact. If you cannot restore, you do not have a backup.
For high stakes apps, combine PITR with read replicas and multi region deployments. That way you can recover from mistakes without downtime or lost data.
Environment variables without the oops#
Secrets are a common leak. The rule is simple: anything with NEXT_PUBLIC_ is visible in browser code. Only use anon keys there.
Keep secrets like service role keys in .env.local, and never commit that file. Document what is required in .env.example. Then in your code, create two Supabase clients: one safe for the browser, one for server side code.
For bigger teams, a secrets manager like Doppler, Vault, or GitHub's encrypted environment variables makes rotation and auditing easier.
Branches that match reality#
Your Git branches should map to your environments. Keep it simple: main for production, develop for staging, and feat/* for features. Supabase will even create preview branches for you automatically when you open a PR. Each one is a fully isolated Supabase instance with unique credentials, perfect for testing features before they hit staging.
This structure keeps your workflow clean and prevents confusion about which branch is safe to merge.
Your deployment rituals#
With all the pieces in place, you need habits to tie them together.
For daily development:
- Start Supabase locally with
supabase start - Branch from
develop - Make schema changes with migrations
- Test with
supabase db reset - Commit and push
For deployments:
- Open a pull request from your feature branch into
develop - Test your changes in staging
- Open a pull request from
developintomain - Merge to deploy to production
- Monitor production for 15 minutes to catch issues quickly
Pull requests are not just ceremony. They create an audit trail, trigger preview branches, and give you a chance to test before you touch production. That small delay saves hours of recovery work later.
Build in a weekend. Scale to millions.#
Let's say your weekend project took off and people are using it. Let's build separate dev and prod environments. To start, you will create two Supabase projects instead of one. Use the dev project for breaking things, keep the production project for your users. After that, you'll set up Vercel to automatically use the right database for each environment.
Separate your environments#
Create a development project in the Supabase Dashboard and name it yourapp-dev. Rename your existing project to yourapp-prod for clarity. Now you have a safe place to experiment.
Extract your production schema and turn it into migration files:
_10npm install supabase --save-dev_10supabase init_10_10# Capture your production schema_10supabase link --project-ref YOUR_PROD_PROJECT_ID_10supabase db pull_10_10# Apply the same schema to development_10supabase link --project-ref YOUR_DEV_PROJECT_ID_10supabase db push
This creates migration files in supabase/migrations/ that represent your current database structure. These files are your new source of truth for schema changes.
Configure Vercel environments#
Tell Vercel which database to use for each deployment. Go to your Vercel project settings and add environment variables:
Production environment (only main branch):
_10NEXT_PUBLIC_SUPABASE_URL = https://yourapp-prod.supabase.co_10NEXT_PUBLIC_SUPABASE_ANON_KEY = your_prod_anon_key
Preview environment (all other branches):
_10NEXT_PUBLIC_SUPABASE_URL = https://yourapp-dev.supabase.co_10NEXT_PUBLIC_SUPABASE_ANON_KEY = your_dev_anon_key
Update your local .env.local to point to the dev project so you never accidentally test against production data.
Your new daily workflow#
The workflow stays almost identical to what you know, with one key difference: you never touch the production Supabase Dashboard again.
For regular features:
- Code locally (automatically uses dev database)
- Push any branch to get a Vercel preview:
git push origin feature/new-comments. Vercel automatically creates a preview URL likeyourapp-git-feature-new-comments.vercel.appthat connects to your dev database with safe test data. - Test the preview URL with fake data
- Merge to
mainwhen ready: Create a pull request on GitHub, review your changes, then merge. Vercel automatically deploys to your production domain using the production database.
For database changes:
- Create a migration:
supabase migration new add_comments_table - Write SQL in the generated file
- Test on dev:
supabase db push - Commit the migration file and push:
git add supabase/migrations/thengit commit -m "Add comments table"thengit push origin feature/comments. The migration file gets committed to your repo like any other code. - Production gets the same changes automatically
Automate production deployments#
Without automation, you would need to manually apply database changes to production every time you merge code. That means remembering to run supabase db push against your production project, which is error-prone and easy to forget.
GitHub Actions solves this by watching your repository and automatically running commands when specific events happen. Set up GitHub Actions to handle production database changes. Create .github/workflows/deploy.yml:
_17name: Deploy to Production_17on:_17 push:_17 branches: [main]_17_17jobs:_17 deploy:_17 runs-on: ubuntu-latest_17 steps:_17 - uses: actions/checkout@v4_17 - uses: supabase/setup-cli@v1_17 - name: Apply migrations to production_17 run: |_17 supabase link --project-ref ${{ secrets.PRODUCTION_PROJECT_ID }}_17 supabase db push_17 env:_17 SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
This file tells GitHub: "Every time someone merges code into the main branch, automatically connect to the production Supabase project and apply any new migration files." Vercel handles your frontend deployment, but your database changes need this extra step.
Here is what happens when you merge a pull request:
- Vercel automatically deploys your new frontend code to production
- GitHub Actions triggers and connects to your production Supabase project
- Any new migration files get applied to the production database
- Your frontend and database stay in perfect sync
Add your project IDs and access token to GitHub secrets. Now every merge to main automatically applies your migrations to production. No more forgetting to update the database. No more manual steps that can go wrong at 2am.
The safety net effect#
Every branch you push creates a preview deployment that uses development data. You can test destructive changes, experiment with new features, and invite others to try things without any risk to production.
The key insight is that your development and production environments stay perfectly in sync through migrations. When a migration works in development, it will work in production. No more schema drift, no more surprise failures.
Enable Row Level Security immediately#
Your weekend project probably skipped RLS. Fix this now before you ship new features:
_10ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;_10CREATE POLICY "Users can only access their own data" ON your_table_10 FOR ALL USING (auth.uid() = user_id);
Apply these policies to both environments. RLS is your last line of defense against data breaches.
Even when building weekend projects, build with RLS enabled. You will write better, cleaner, safer code, and your database will protect you in case you don't. Most coding agents can help translate natural language (e.g., "People should only be able to see rows that belong to them in this table") into the necessary SQL for RLS.
This setup takes one afternoon to implement but eliminates the fear of breaking production. You can move fast again while your users stay protected. The same tools, the same workflow, just organized safely.
Final word#
The path from vibe coder to confident deployer is not about memorizing every DevOps buzzword. It is about a handful of patterns that keep you safe: separate environments, migrations as save points, automated deployments, tested backups, and strict RLS. Supabase makes this easy because everything is Postgres, deeply integrated, and scalable from weekend project to millions of users.