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

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](https://www.tinbase.dev/?utm_source=hashnode&utm_medium=blog&utm_campaign=supabase-schema-changes-migrations-without-docker), 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](https://pglite.dev), 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:

```bash
npm i -D tinbase @supabase/supabase-js
```

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

```sql
-- 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:

```sql
-- 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:

```bash
npx tinbase start
```

```text
  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):

```sql
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:

```bash
npx tinbase db diff
```

```sql
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:

```bash
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:

```text
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

```bash
npx tinbase db reset
```

```text
  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:

```sql
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:

```bash
npx tinbase db pull project_status
```

```text
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:

```bash
npx tinbase status
npx tinbase db diff
```

```text
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:

```bash
npx tinbase gen types typescript > database.types.ts
```

```typescript
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:

```typescript
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:

```text
error: relation "public.nope" does not exist
```

Here is the whole workflow:

```yaml
# .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](https://supabase.com/docs/guides/deployment/database-migrations) covers the deploy side of the same workflow, and the [Tinbase source on GitHub](https://github.com/tinbase/tinbase) 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.
