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
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:
Migrations replay on an empty database. If someone hand-edited an old migration or committed one that depends on local state, this fails.
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 pushdeploys them as-is.seed.sqlis the same filesupabase db resetreads.database.types.tshas the same shapesupabase gen typesemits, 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 diffto see what drifteddb pullto capture it and keep your data, ordb diff -fplusdb resetto capture it and prove it replaysgen typesafter every schema changemigrate --memoryand 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.
