Skip to main content

Command Palette

Search for a command to run...

Stop Losing Studio Schema Changes: A Docker-Free Supabase Migration Workflow

db diff, db pull, db reset and gen types in one process, plus a CI check for schema drift

Updated
•10 min read•View as Markdown

Every Supabase team has had this conversation. Someone adds a column in the Studio table editor because it was faster, the feature works on their machine, the PR gets merged, and then staging breaks because the column only ever existed in one person's local database. Nobody wrote the migration.

The Supabase CLI has the right tools for this: supabase db diff turns local schema changes into a migration file, supabase db reset proves the migrations replay from scratch, and supabase gen types keeps the TypeScript client honest. The problem is that all three need the local stack running, and the local stack is about a dozen Docker containers. When the stack is heavy, people skip the loop.

This post walks through the same loop with every step running in a single Node process, no Docker involved. By the end you will have:

  • A schema change made "the fast way" in Studio, captured as a proper migration

  • One gotcha that will break your next start if you don't know about it, and two ways around it

  • Generated types that match the schema

  • A CI job that fails when a migration doesn't replay or the types drift

The setup

The backend here is Tinbase, an MIT-licensed, Supabase-compatible backend that runs as one process with real Postgres underneath. It reads the same supabase/migrations/*.sql and supabase/seed.sql files as the Supabase CLI, tracks them in the same supabase_migrations.schema_migrations table, and ships CLI commands that mirror the ones you already know:

Supabase CLI Tinbase What it does
supabase start tinbase start Boot the backend, apply pending migrations
supabase db diff -f name tinbase db diff -f name Write schema changes not yet in migrations to a new file
supabase db reset tinbase db reset Wipe, replay all migrations, run the seed
supabase gen types typescript --local tinbase gen types typescript Print a Database type for supabase-js
supabase migration list tinbase status List applied migrations

There is also a tinbase db pull, which matters more than it looks. More on that below.

Tinbase has three database engines. The default on macOS and Linux is an embedded native Postgres 17 (it downloads about 12 MB of binaries on first run). The wasm engine runs PGlite, Postgres compiled to WebAssembly, and is the default on Windows. Everything in this post works on both; the commands below were run on the wasm engine.

One honest caveat: Tinbase is in alpha (v0.19.0 at the time of writing). It is built for local development, tests and previews. Your production database stays on hosted Supabase, and because the migration files are plain Supabase CLI files, they deploy there unchanged.

Step 1: Start from a real migration

You need Node 22 or newer. In a new or existing project:

npm i -D tinbase @supabase/supabase-js

Here is the starting schema, a projects table with owner-only RLS:

-- supabase/migrations/20261013000000_projects.sql
create table public.projects (
  id bigint generated always as identity primary key,
  owner_id uuid not null references auth.users (id) default auth.uid(),
  name text not null,
  created_at timestamptz not null default now()
);

alter table public.projects enable row level security;

create policy "owners read their projects" on public.projects
  for select to authenticated using (owner_id = auth.uid());

create policy "owners create projects" on public.projects
  for insert to authenticated with check (owner_id = auth.uid());

And a seed so the database is never empty after a reset:

-- supabase/seed.sql
insert into auth.users (id, email)
values ('00000000-0000-0000-0000-000000000001', 'demo@example.com');

insert into public.projects (owner_id, name)
values ('00000000-0000-0000-0000-000000000001', 'Demo project');

Boot it:

npx tinbase start
  tinbase running

           API URL: http://127.0.0.1:54321
          Admin UI: http://127.0.0.1:54321/_/
        Migrations: 1 file(s)

          anon key: eyJhbGciOiJIUzI1NiIs...
  service_role key: eyJhbGciOiJIUzI1NiIs...

  Same keys every start - safe to commit. Drop into .env.local:

Those keys are the well-known Supabase local-dev demo keys, identical on every machine, so a new teammate can clone the repo, run npx tinbase start, and use the committed .env.local without any copy-pasting.

Step 2: Make the change the fast way

Open the Admin UI at http://127.0.0.1:54321/_/, sign in with the service_role key, and run this in the SQL editor (or add the column in the table editor, the result is the same):

alter table public.projects
  add column archived boolean not null default false;

create index projects_owner_idx on public.projects (owner_id);

Your app can use archived right away. This is exactly the moment the schema drifts from the repo: the live database has a column and an index that no file in supabase/migrations knows about.

Step 3: Turn the drift into a migration

Stop the server, then ask for the diff:

npx tinbase db diff
alter table "public"."projects" add column "archived" boolean not null default false;

CREATE INDEX projects_owner_idx ON public.projects USING btree (owner_id);

Under the hood, Tinbase replays your migration files into a throwaway shadow database and compares its schema against the live one. Whatever is left over is the drift. When it looks right, write it to a file:

npx tinbase db diff -f archive_projects
# Wrote supabase/migrations/20261013072400_archive_projects.sql

On the default native engine, db diff attaches to a running server, so you can skip stopping it. On the wasm engine it refuses while a server holds the data directory, because PGlite is single-writer and two processes on one set of files is how databases get corrupted.

The gotcha: the file exists, but nothing says it was applied

Run any command that applies pending migrations right after db diff -f, and you get this:

error: column "archived" of relation "projects" already exists

It makes sense once you see it. db diff -f writes the file but does not record it in supabase_migrations.schema_migrations. So the next start sees a new, unapplied migration and tries to run it against a database that already has the column.

There are two clean ways out, and they suit different moments.

Option A: db reset when you want proof

npx tinbase db reset
  wiped database and storage
  applied migrations: 20261013000000_projects, 20261013072400_archive_projects, seed.sql
  reset complete - 2 migration(s) applied + seed

This throws away the local database and rebuilds it purely from files. If the new migration has a mistake, you find out now instead of in staging. In my test run this took about four seconds end to end, which is cheap enough to do after every schema change. Like the diff, db reset refuses to run while a server is using the data directory, so it can never wipe a database out from under a running process.

The trade-off: any test data you typed into the app is gone, and you are back to seed.sql.

Option B: db pull when you want to keep your data

Say you spent twenty minutes building up realistic data and now need a status column:

create type public.project_status as enum ('active', 'paused');

alter table public.projects
  add column status public.project_status not null default 'active';

Instead of db diff -f, stop the server and run:

npx tinbase db pull project_status
Wrote supabase/migrations/20261013072447_project_status.sql and recorded it as applied (version 20261013072447).

db pull writes the same diff to a file and marks it as already applied, so the next start skips it and your data survives. Confirm everything lines up:

npx tinbase status
npx tinbase db diff
20261013000000  20261013000000_projects
20261013072400  20261013072400_archive_projects
20261013072447  20261013072447_project_status
No schema changes found.

"No schema changes found" is the state you want before every commit. A good rule of thumb: use db pull while you are iterating, and run one db reset before you open the PR, so you have proof the files replay without your local history helping them along.

Step 4: Regenerate types

The archived and status columns now exist in SQL, but supabase-js does not know about them until the types catch up:

npx tinbase gen types typescript > database.types.ts
export type Database = {
  public: {
    Tables: {
      projects: {
        Row: {
          id: number
          owner_id: string
          name: string
          created_at: string
          archived: boolean
          status: Database["public"]["Enums"]["project_status"]
        }
        // Insert, Update, Relationships ...
      }
    }
    // Views, Functions, Enums, CompositeTypes ...
  }
}

gen types builds the schema from your migration files in a fresh in-memory database, not from whatever is sitting in your local data directory. That is a useful property: the types describe what the repo says the schema is, which is what production will get.

Wire it into the client the usual way:

import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'

const supabase = createClient<Database>(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!,
)

// `archived` and `status` are now typed; a typo is a compile error
const { data } = await supabase
  .from('projects')
  .select('id, name, status')
  .eq('archived', false)

Step 5: Catch drift in CI

Local discipline only goes so far. Two checks in CI catch the rest:

  1. Migrations replay on an empty database. If someone hand-edited an old migration or committed one that depends on local state, this fails.

  2. Committed types match the migrations. If someone changed the schema and forgot to regenerate, this fails.

tinbase migrate --memory applies every migration plus the seed to a throwaway in-memory database and exits non-zero on the first error. A broken migration looks like this:

error: relation "public.nope" does not exist

Here is the whole workflow:

# .github/workflows/schema.yml
name: schema
on: [pull_request]

jobs:
  schema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci

      - name: Migrations and seed replay from scratch
        run: npx tinbase migrate --engine wasm --memory

      - name: Generated types are up to date
        run: |
          npx tinbase gen types typescript > database.types.ts
          git diff --exit-code database.types.ts

No services: block, no Docker setup step, no waiting on containers. The migrate check took a little over three seconds for this project in my test run. The --memory flag only exists on the wasm engine, which is why it is set explicitly here.

Note that this checks your migrations against Postgres, not against your hosted Supabase project. It will not tell you that production already has a column someone added through the dashboard. For that, supabase db diff --linked against the real project is still the right tool.

What carries over unchanged

Nothing in this workflow is Tinbase-specific on the output side:

  • Migration files are plain SQL in supabase/migrations, named the way the Supabase CLI names them. supabase db push deploys them as-is.

  • seed.sql is the same file supabase db reset reads.

  • database.types.ts has the same shape supabase gen types emits, so swapping generators later is a one-line change in your scripts.

The Supabase database migrations guide covers the deploy side of the same workflow, and the Tinbase source on GitHub documents the engines and known gaps if you want to check how close the compatibility is for your project.

Wrapping up

Schema drift is rarely a knowledge problem. Most teams know they should diff, reset and regenerate after every change. They skip it because the loop is slow and the stack is heavy. When the whole loop is a few seconds in one process, it stops being a chore and becomes the default.

The short version:

  • Change the schema however you like, then db diff to see what drifted

  • db pull to capture it and keep your data, or db diff -f plus db reset to capture it and prove it replays

  • gen types after every schema change

  • migrate --memory and a types diff in CI

How does your team handle schema changes made in Studio: banned outright, or allowed as long as a migration follows? I'd like to hear what works in the comments.