Supabase Database Migrations: Staging to Production
Supabase database migrations: staging to production. Safe patterns, RLS policies, backwards compatibility, and when to use managed hosting like Ship.
- Safe migrations follow a four-phase workflow: local, staging, review, production
- RLS policies fail silently if migrations don't update them in the same transaction
db diffworks for simple schema changes but misses triggers, functions, and complex constraints- Data migrations should be separate from schema migrations to isolate failures and enable partial recovery
- Northflank starts at $2.70/month for managed PostgreSQL; Ship adds full deployment orchestration
Safe database migrations from staging to production require more than following CLI commands. You need to understand what can break, when db diff fails, and how schema changes interact with row-level security (RLS) policies. This guide walks through the patterns that prevent 3am panic calls: the four-phase workflow, the manual SQL tradeoff, the RLS gotchas, and when to stop managing this yourself and reach for a managed platform.
Why Does Schema Safety Matter?
Database migrations are not regular deployments. A broken migration can corrupt data, break relationships, or leave you unable to roll back. The cost of a failed production migration is measured in downtime, customer impact, and the time your team spends debugging permission cascades at 3am.
Here's the problem: Supabase gives you multiple ways to modify your schema. You can use the dashboard, the CLI, or raw SQL. Each has different failure modes. The dashboard lets you see changes instantly, but if something breaks, you have no audit trail. The CLI generates diffs automatically, but db diff can miss complex changes like triggers, functions, or policy interactions. Raw SQL is explicit but error-prone when teams don't coordinate.
Schema drift happens silently. Your staging environment changes five times. Your production environment drifts in a different direction. By the time you notice, they're incompatible. A migration that worked on staging fails on production because the actual schema state differs from what you expected. This is the hidden cost of multiple environments: keeping them synchronized requires discipline.
The specific risk with Supabase: RLS policies depend on your schema. If you add a column, rename a table, or drop a foreign key, existing policies can suddenly block reads or writes you didn't expect. A policy that filters by user_id breaks if a table no longer has that column. Cascading constraint changes can propagate in ways that aren't obvious until production is broken and your app is returning permission errors to paying customers.
What's the Anatomy of a Safe Migration?
Safe migrations follow a four-phase workflow: make the change in local dev, test it in an isolated staging environment, prepare a deployment plan, and execute it in production with a rollback strategy ready. Each phase verifies the change works without breaking dependencies, and each phase is reversible.
Start with your local environment. Create a feature branch and make schema changes there. Use supabase db reset to wipe your local database and verify your migration runs cleanly on a fresh state. This catches syntax errors and obvious breaks before they touch staging. Run your application against this environment. Does every API endpoint still work? Do your queries still return data? Are there new permission errors you didn't expect?
Next, push to a staging environment. Supabase branches are perfect for this: every pull request gets its own preview database, pre-populated with production-like data or a recent snapshot. This is where you catch the real gotchas. You're now running against data that resembles production: millions of rows, complex relationships, realistic index sizes. A migration that takes 100ms on local can take 30 seconds on staging because you're locking a table with real data.
Generate the migration file. Supabase's db diff compares your current state to your desired state and generates a SQL file. Review this file carefully. Does it drop columns you meant to keep? Does it recreate indexes unnecessarily? For complex changes (adding computed columns, changing constraints), write the SQL by hand instead. Version control that migration file: it's your audit trail of what changed and when.
Test backwards compatibility. Your new schema must work with the old version of your application code. If you rename a database column from created_at to createdAt, your old app code will fail. If you deploy the migration first and the code second, there's a window where they're out of sync. Use feature flags or dual-write patterns: write to both old and new column names for one deploy cycle, then migrate the reads, then drop the old column. This takes extra work but prevents customer-facing breakage.
Document your rollback. Before you deploy, know how you'll undo the migration. Can you run a reverse migration? Will Supabase PITR (point-in-time recovery) give you back what you lost? Are you willing to restore from a backup and replay transactions manually? The answer should not be "I'll figure it out when it breaks."
Where Does db diff Fail, and When Do You Write SQL Manually?
db diff is reliable for simple schema changes: adding tables, adding columns, changing column types. It fails on functions, triggers, policies, and complex constraints. The GitHub discussions on this topic are clear: developers explicitly doubt its reliability, especially when handling business logic changes. Professional teams write critical migrations by hand.
db diff works when the change is structural: "add column user_tier VARCHAR(50)". It struggles with logic: "write a trigger that auto-updates a denormalized count column when rows change." You can write the trigger manually in Supabase, take a snapshot with db pull, and db diff will see the trigger in your schema file. But if you're relying on auto-generation, you'll miss it.
The safe pattern: use db diff for schema scaffolding, then review and edit the generated SQL by hand. Look for these red flags:
- Are you dropping columns you meant to keep?
- Are indexes being recreated (slow on large tables)?
- Are triggers or functions missing?
- Do foreign key constraints have the right cascading behavior?
- Are you changing column types that will fail if existing data can't cast to the new type?
For complex changes, write the migration from scratch. A rule of thumb: if you can't explain the migration in one sentence, you should write it manually. "Add a new status column with a default value" gets db diff. "Rename a column, backfill existing rows, add a computed column that depends on it, and update three RLS policies" gets manual SQL.
Manual migrations also let you batch changes. Instead of running ten separate migrations (each one locks the table briefly), you can combine safe operations into one transaction. This reduces downtime and is more testable. Your application sees fewer total migration events, which means fewer opportunities for something to break mid-deployment.
How Do You Handle Schema Changes That Affect RLS Policies?
RLS policies are triggered by schema state. If a policy references a column that no longer exists, the policy fails silently at query time, blocking reads or writes. This is the "unsolved half" of migrations that GitHub discussions flag: no clear guidance on coordinating schema changes with policy updates.
Here's a concrete example. You have a policy that filters on user_id:
CREATE POLICY "users_see_own_posts"
ON posts
FOR SELECT
USING (user_id = auth.uid());
This works as long as posts has a user_id column. Now you rename user_id to author_id. Your migration renames the column. The policy still references user_id. At query time, PostgreSQL evaluates the policy but user_id doesn't exist. The query fails with a cryptic error, or worse, silently blocks all access to the posts table.
The safe pattern: update policies in the same migration that changes the schema they depend on. Don't deploy schema changes and policy updates in separate migrations. In your SQL migration file:
-- Rename the column
ALTER TABLE posts RENAME COLUMN user_id TO author_id;
-- Update the policy to match
DROP POLICY "users_see_own_posts" ON posts;
CREATE POLICY "users_see_own_posts"
ON posts
FOR SELECT
USING (author_id = auth.uid());
This ensures the schema and policies stay in sync. If the migration fails halfway, you roll back both. If it succeeds, both are correct.
Another gotcha: row-level security is evaluated on the client-side auth context. If you add a new column that a policy filters on, you need to ensure your Supabase client is properly configured to pass the auth context. A policy that suddenly relies on a new user_role column will fail if your app isn't sending the role as part of the JWT. Test this explicitly in staging before promoting to production.
Computed columns and generated columns can also break policies. If a policy filters on a computed value that depends on a column you're changing, the policy logic changes unexpectedly. Trace these dependencies before you deploy. A column change that seems isolated can cascade through policy logic in ways you didn't plan for.
What's the Difference Between Schema Migrations and Data Migrations?
Schema migrations change table structure, columns, constraints. Data migrations transform existing values. They are different operations with different safety implications, and mixing them is a common source of failures.
A schema migration adds a new column. A data migration backfills that column with computed values based on existing data. Schema first, data second, in separate migrations. Why? Because if a data backfill fails partway through (because of bad data, locks, or permissions), you can fix the data and re-run the backfill without re-running the schema change. If they're in the same migration, you have to roll back the entire thing, including the schema.
Example:
-- Migration 1: Add the column (schema change)
ALTER TABLE users ADD COLUMN full_name TEXT;
-- Migration 2: Backfill the column (data change)
UPDATE users SET full_name = first_name || ' ' || last_name WHERE full_name IS NULL;
If migration 1 succeeds and migration 2 fails (because of bad data in first_name or last_name), you can fix the data and re-run migration 2. Your schema is already in place.
Data migrations on large tables (millions of rows) need extra care. A backfill that updates every row will lock the table. Depending on Supabase's concurrency settings and your data size, this could take minutes or hours. During that time, writes to the table are blocked. Applications waiting to insert data will hang.
Professional teams batch data migrations. Instead of running UPDATE users SET full_name =... on 10 million rows, they iterate in chunks:
UPDATE users SET full_name = first_name || ' ' || last_name
WHERE full_name IS NULL AND user_id >= 1000000 AND user_id < 1010000
LIMIT 10000;
Run this in a loop, processing 10,000 rows at a time, with a pause between batches. This prevents long locks and spreads the I/O load. It's slower overall (because you run many queries instead of one) but it's safer for production. Your app can keep serving reads and writes while the backfill runs in the background.
How Do You Test Backwards Compatibility Before Production?
Backwards compatibility means your new schema works with the old version of your application code. If your app is running v1.0 in production and v2.0 (with updated code) is being deployed, the new schema must not break v1.0.
The scenario: you rename a database column from created_at to createdAt (style preference). Your v1.0 app queries the created_at column. Once the migration runs, v1.0 is broken. If you deploy the code and schema at the exact same time, you're okay. But if the migration runs first (or code deploys second), there's a window where they're out of sync and production is broken.
Safe pattern: use feature flags or dual-write strategies.
- Deploy code that writes to both
created_atandcreatedAt. - Run the migration to rename the column, but keep
created_atas a view or computed column that returnscreatedAt. - Deploy the code change that reads only from
createdAt. - Once you're confident everything is working, drop the
created_atview in a separate migration.
This is more work but ensures zero downtime. The alternative (coordinating a synchronized deploy where code and schema must deploy together) works if you have only one application instance and can guarantee perfect synchronization. For anything larger, assume they'll drift.
Test your migration in staging with both old and new versions of your application. Does the old code still work? Does the new code work? Does switching between them (rolling back your app code) work without data loss? Use Supabase branches for this: deploy old code to branch A, new code to branch B, both against the same schema after migration. Verify both work before you touch production.
When Does DIY Hit Its Limits, and What's the Cost of Getting It Wrong?
Database migrations are only one part of a scalable application setup. Once you're managing multiple environments (local, staging, production), multiple team members, and real production traffic, DIY starts showing its costs: coordination overhead, human error, and unplanned downtime.
A failed production migration has three costs: the immediate incident (downtime, customer impact, damage to trust), the recovery time (getting data back, debugging what went wrong, re-running the migration safely), and the opportunity cost (your engineering team spent three hours fixing a migration instead of building features).
For a 20-person company, a 2-hour production outage might cost $500 in direct SLA violations plus the value of three engineers' time at 3 hours each. If you have two or three migration incidents per year, you're at $2,000-$3,000 in avoidable costs, plus the cumulative reputation damage.
Supabase itself is good at what it does: PostgreSQL with a REST API and real-time subscriptions. But the deployment infrastructure around it is your responsibility. You handle CI/CD, environment parity, schema versioning, and rollback strategy. This is workable at small scale but becomes a liability as your product scales. The DIY approach scales with team complexity, not linearly.
Northflank offers managed PostgreSQL starting at $2.70/month, which gives you managed backups and simpler operations. Supabase gives you PostgreSQL plus the REST API, which is more feature-rich but requires you to own the deployment complexity. The tradeoff is real: you save money (Supabase is cheaper), but you trade time and risk for that savings.
Ship takes this further. It's a full PaaS that handles not just the database but your entire application deployment, environment management, CI/CD, and zero-downtime deployments. The cost is higher, but the operational overhead is lower. You push code, Ship handles staging, production, migrations, and rollback automatically. The question for your team: is your time worth more than the hosting savings?
How Do Managed Hosting Platforms Like Ship Simplify This?
Ship is a platform designed specifically for this problem. It automates the parts of migrations that cause failures: environment parity, schema versioning, and coordinated deployments. It integrates database migrations into your CI/CD pipeline so that code and schema changes deploy together, predictably.
Here's how it changes the workflow:
- Push code to GitHub. Ship watches your repository.
- Every PR automatically gets a preview environment with a fresh database snapshot from production.
- Schema changes in your code (migrations, ORM definitions, database setup files) are automatically detected and applied to the preview.
- You test against the preview database, which is isolated and safe.
- When you merge to main, Ship automatically runs the schema migration on production, using a coordinated deployment strategy that ensures code and schema deploy together.
- If something breaks, Ship can automatically roll back the schema and redeploy.
This removes the manual steps: no "supabase db push staging" followed by "supabase db push production" in separate terminals. No risk of running migrations out of order or forgetting to run them. No manual backfill scripts that get skipped. No coordination overhead between the database team and the application team.
The cost of Ship is higher than Supabase alone (predictable monthly pricing instead of per-usage costs), but you get predictability. You know what you're paying. You know your team won't spend weekend hours debugging a migration that went wrong. You know your staging environment is always up-to-date with production schema, so there's no surprise drift that breaks your tests.
For context: Ship also handles other hosting responsibilities (load balancing, SSL, secrets management, monitoring). It's a full platform, not just database hosting. If you're comparing to Supabase alone, Ship's real value is the orchestration and environment management, not just the database. The integration means your entire deploy process is one button, not five manual steps.
Frequently Asked Questions
How do you push migrations to Supabase?
Use supabase db push <branch> to apply migrations to a specific environment. Migrations run in order based on their timestamp. If a migration fails, Supabase stops and reports the error. You must fix the migration and re-run it. Always test locally with supabase db reset and in staging before pushing to production. A failed migration in production is far more expensive to fix than a failed migration caught in testing.
How do you rollback a migration in Supabase?
Supabase doesn't have an automatic "undo migration" command. Write a rollback migration instead: a new migration file that reverses the changes. If a migration added a column, the rollback drops it. If a migration created a table, the rollback drops the table. For critical incidents, use PITR (point-in-time recovery) to restore to a point before the bad migration, then redeploy safer migrations. PITR is faster but more destructive because it rolls back all changes since that point.
How do database migrations work?
Migrations are version-controlled SQL files that modify your database schema. Each migration has a unique timestamp or version number. When you run migrations, the system executes them in order, tracking which ones have already run to prevent duplicates. If a migration fails halfway through, the system rolls back the transaction (if it's a single transaction) or stops and requires manual intervention. This ensures your schema versioning stays in sync with your code.
What are common gotchas when migrating Supabase schemas?
RLS policies can fail silently if they reference columns that no longer exist after a migration. Schema drift between environments causes migrations to succeed in staging but fail in production. Long-running migrations (backfilling millions of rows) lock tables and block writes. Renaming columns or tables breaks application code that expects the old names, unless you coordinate code and schema deployments carefully.
When should you use manual SQL instead of db diff?
Use manual SQL for complex changes: dropping constraints, adding computed columns, writing triggers, or updating multiple related tables. Use db diff for simple structural changes. Always review the generated SQL, even for simple changes, because db diff can miss some complex dependencies.
The Bottom Line
Supabase database migrations are straightforward for simple changes but grow complex as your application scales. The safe approach requires separating schema and data changes, testing backwards compatibility, coordinating with RLS policies, and planning rollback strategies before you deploy.
Your team must decide whether this ongoing operational burden is worth the savings of DIY hosting. For startups focused on runway, Supabase is the right choice. For teams at scale (50+ people) where an outage costs measurable money and time, managed platforms like Ship make sense. Ship handles the orchestration, environment parity, and coordinated deployments that reduce surprises.
The first step: implement the four-phase workflow (local, staging, review, production) and never skip the staging test. The long-term step: as your infrastructure matures, consider whether managing migrations yourself is still the best use of your team's time. Deploy your application to Ship with Opsily' managed hosting, and let the platform handle the complexity.