aboutlabnotesuses
Open to Work

Join my newsletter to stay updated about the latest things I've created, discover the great finds, and get productivity hacks and great tools.

  • wassimbenr@gmail.com
notes

How to Set Up Supabase Locally with the CLI (2026)

March 13, 2026 · updated October 16, 2026

Stop Building Supabase Projects from the Dashboard

Most developers start with Supabase by signing into the Dashboard, opening the Table Editor, and clicking their way through schema creation. It works, until you need to share that schema with a teammate, deploy to a staging environment, or roll back a breaking change. Then you realize your entire database structure lives in the cloud with no version control and no reproducible setup.

I've onboarded developers across dozens of startups, some using Next.js, others FastAPI or Flutter. The stack doesn't matter. The first thing I always set up is the same: a local-first Supabase workflow using the CLI. Schema as code. Migrations in Git. One command to deploy.

This post walks through the exact workflow I recorded in my YouTube video building Trelly, a task management app with Next.js and Supabase. The October 2026 section covers the experimental CLI stack: one local project per git worktree, with Docker or without it.

Why Local-First Matters

The Supabase Dashboard is great for exploration. But when you use it as your primary development tool, you're building on sand:

No version control. Your schema exists in Supabase's cloud. There's no file in your repo that describes your database. If you delete a column by accident, there's no Git history to recover from.

No reproducibility. A new teammate joins the project. How do they get the same database? Screenshots? A Notion doc? With a local-first workflow, they clone the repo, run two commands, and they have the exact same database.

No safe deployment path. Moving schema changes from development to production means manually recreating them in the cloud Dashboard. Miss one constraint, one RLS policy, one index, and you've got a production bug.

The CLI workflow fixes all three. Your schema is SQL files in your repo. Migrations are tracked in Git. Deployment is one command.

Prerequisites

Two things:

Supabase CLI v2.119.0 or later. Install via Homebrew, npm, or Scoop depending on your OS. The official docs cover all methods. Check with supabase --version. The experimental stack in this post shipped in v2.119.0. Port changes on an existing local project need v2.121.0 or later.

Docker or Podman, recommended. When a daemon is running, supabase start uses it. That is still the better fit for several projects on one laptop, because each container gets its own network and file system. Docker is not required everywhere. On Apple silicon with macOS 14 or later, and on Linux amd64 or arm64 with glibc 2.35 or later, the CLI can run the same services as plain processes. Windows and Intel Macs still need Docker.

Initialize the Project

mkdir trelly && cd trelly
supabase init

This creates a supabase/ directory with:

  • config.toml, configures your entire local environment: auth providers, email settings, ports, Studio UI. Most tutorials never mention this file, but it's where you configure OAuth providers locally and customize your setup.
  • migrations/, where your schema SQL files live.
  • seed.sql, test data that runs after every migration reset.

If this repo will host more than one local project, init with the experimental flag instead. That setup is in One local project per agent, below.

Start the Local Environment

supabase start

This brings up the Supabase stack: Postgres, GoTrue (auth), PostgREST (auto-generated REST API), Studio (the Dashboard UI), Realtime, and Storage.

The default command still uses Docker when a Docker or Podman daemon answers. The first run pulls images. Later starts reuse that cache.

Once the experimental stack is on, Postgres starts immediately and every other service stays asleep until something asks for it. Auth, Storage, and Studio use nothing until the first request, then stop again when idle. Pass --eager if you want every enabled service up before the command returns.

Run supabase status for the API URL, Studio URL, publishable key, secret key, MCP endpoint, and database connection string. Open the Studio URL from that output. A single default project often lands on localhost:54323. After you run more than one project, that port is wrong. Read it from status every time.

One Local Project Per Agent

In October 2026, CLI v2.119.0 shipped an experimental engine that runs several local projects on one machine, one per app or git worktree, with or without Docker. Supabase wrote it up in A local Supabase project per agent, with or without Docker. The reference is the multiple local projects guide.

Suppose one agent is adding billing while another reworks signup. Each gets a worktree and its own database. supabase db reset in one does not touch the other.

This engine is off until you turn it on. Flags and output can still change. A project started this way keeps its own data, separate from one you started with the old supabase start. Turning the setting on does not copy that older database.

For a new app, init with the flag. The CLI writes stack = true and leaves the port keys out:

SUPABASE_EXPERIMENTAL_STACK=1 supabase init

For an app that already has supabase/config.toml, add stack = true under the existing [experimental] table. Do not add a second [experimental] table. A duplicate makes the file invalid and the stack commands stay off.

[experimental]
stack = true

Then remove the fixed ports. supabase init writes a port under [api], [db], [db.pooler], [studio], [local_smtp], and [analytics], plus shadow_port under [db] and inspector_port under [edge_runtime]. The CLI treats a port in the file as an exact request, so two projects that both ask for 54321 cannot run at the same time. Commit config.toml so every worktree gets the setting. The port-removal checklist is the full list, including the SMTP and analytics ports init leaves commented out.

If a local project is already running, stop it before editing ports:

supabase stop

Remove the port keys, then supabase start again. CLI v2.121.0 or later applies the new ports and keeps your data. It prints each move, such as api: 54321 → 26199. Earlier versions fail with The saved stack cannot adopt these changes. On those, start a new named project or run supabase stack destroy and start again. Destroy deletes that local project's data. There is no backup.

Give each agent a worktree and start both:

git worktree add ../trelly-billing -b billing
git worktree add ../trelly-signup -b signup

cd ../trelly-billing && supabase start
cd ../trelly-signup && supabase start

Each worktree gets its own Postgres, Storage, Edge Functions, and MCP server. The CLI picks ports in the range 20000 to 32767 and remembers them across restarts. With stack = true, supabase start, supabase status, and supabase stop route to this engine. supabase stack list shows every local project on the machine.

Do not assume 54321, 54322, or 54323. Read URLs and keys from the CLI:

supabase status --env --output-format text
supabase start --output-format json

--env includes API_URL, DB_URL, PUBLISHABLE_KEY, SECRET_KEY, ANON_KEY, and SERVICE_ROLE_KEY. JSON is what a script or an agent should parse. The local database password is postgres.

Several projects on one laptop: Docker or Podman. One project in a sandbox or CI runner with no container engine:

supabase start --runtime native

Native runs all of the services as plain processes, at the same pinned versions as the Docker images, including pgvector. It runs on Apple silicon with macOS 14 or later, and on Linux amd64 and arm64 with glibc 2.35 or later. Windows and Intel Macs are not supported. Use Docker there. If you omit --runtime, a new project uses Docker when the daemon responds, then Podman, then native on a supported platform. The runtime is fixed for the life of that local project. A later --runtime flag fails. To switch, start a new named project, or destroy this one and start again. The runtimes guide covers sandbox allowlists and supabase stack prepare.

On this engine a cached start is a few seconds, and an idle project uses about a tenth of the memory the old all-at-once stack did. Pass --eager when you want every service up front instead of on the first request.

If you would rather edit schema files and let the CLI write the migration, that is Declarative Schemas 2.0. This post stays on db diff. Remote branches are a separate product: when a pull request opens, the branch inherits Auth, Storage, and Edge Functions from the base project. The Branching guide covers how to create one.

Create the Tasks Table and Generate a Migration

Here's where this workflow differs from every Dashboard tutorial. I created the tasks table using the local Studio UI, but instead of leaving it there, I captured it as a migration file:

supabase db diff -f tasks --local

This compares the current state of your local database against your migration files and generates the difference as a new migration. The result is a timestamped SQL file in supabase/migrations/:

create table "public"."tasks" (
    "id" uuid not null default gen_random_uuid(),
    "title" text not null,
    "created_at" timestamp with time zone not null default now()
);

alter table "public"."tasks" enable row level security;

CREATE UNIQUE INDEX tasks_pkey ON public.tasks USING btree (id);

alter table "public"."tasks" add constraint "tasks_pkey" PRIMARY KEY using index "tasks_pkey";

The file also includes all the grant statements for anon, authenticated, and service_role roles, the full permission setup that Supabase generates.

The important line: alter table "public"."tasks" enable row level security. RLS is on from the start.

The RLS Trap You Need to Know

When you create a table in the Supabase Dashboard (Table Editor), RLS is auto-enabled. Silently. But when you create a table via raw SQL without going through the Table Editor, RLS is off by default. Your table is completely open. Anyone with the anon key can read and write everything.

I've seen this in production across multiple startups. Real user data exposed because the developer wrote SQL without enabling RLS, because they learned from a Dashboard tutorial where it happened automatically.

The fix: always include enable row level security in the same migration file where you create the table. Same file. Same commit.

Reset and Verify

supabase db reset

This drops everything and re-applies all migrations from scratch. Check Studio, your tasks table is there with RLS enabled. The schema is now a SQL file in your repo. It's in Git. Anyone who clones this repo runs supabase start then db reset and gets the exact same database.

db reset hits the default local project for the current branch. A named project started with supabase start --stack test is a different database. Resetting one leaves the other alone.

Push to Production

supabase login
supabase link
supabase db push

supabase login authenticates with your Supabase account. supabase link connects your local project to a cloud project, you create the cloud project once in the Dashboard, the only time you need to touch it. supabase db push applies all your local migration files to production.

Same schema, same RLS, same constraints. Guaranteed, because it's the same SQL files.

Push vs Pull vs Diff

Three commands, three directions:

db push, sends your local migrations to the cloud. Your normal workflow. Use this 90% of the time.

db pull, generates a migration file from what already exists in the cloud. Use this once if you inherited a project built in the Dashboard, then switch to push forever.

db diff, compares your local state and generates the difference as a migration. This is what we used to capture the tasks table.

AI-Assisted Migrations with Claude and MCP

Once the project is running locally, connect Claude to the MCP endpoint from supabase status. Studio serves it at /mcp. Start with --exclude studio and that endpoint is gone, because Studio is what hosts it.

Do not hardcode 127.0.0.1:54322. With more than one local project, that port is not yours. Take the MCP URL from status, or take DB_URL from the same output if you wire a Postgres MCP. The local password is postgres.

supabase status --output-format json

Agents should parse that JSON. The start output includes the project id, the runtime, which services are still asleep, and the same env map --env prints.

This lets Claude read the actual schema, tables, columns, constraints, relationships, not guess from documentation.

I also imported the Supabase Postgres Best Practices skill, which gives Claude context on Supabase conventions so it generates SQL that follows the platform's patterns.

With this setup, I asked Claude to add a description column to the tasks table. It generated a new migration using supabase migration new:

ALTER TABLE tasks ADD COLUMN description text;

Clean, correct, in the right directory. Applied it with db reset and the column was there.

The combination of MCP (so Claude reads your schema) and the Supabase skill (so it follows best practices) is a serious productivity boost. But I review every migration it writes.

The Seed File Trick

Create a seed.sql file in your supabase/ folder with test data, sample tasks with different statuses. Every time you run db reset, Supabase applies all migrations and then runs the seed file.

Your entire team starts from the same test data. I've worked with teams where developers spent thirty minutes a day recreating test data after a reset. The seed file takes ten minutes to write once.

The Full Workflow in 30 Seconds

SUPABASE_EXPERIMENTAL_STACK=1 supabase init → supabase start → build your schema in local Studio → supabase db diff -f <name> --local to capture it as a migration → point Claude at the MCP URL from supabase status → supabase db push to deploy. When two agents are working, give each a worktree and read endpoints from status. Do not reuse 54321, 54322, or 54323. Schema is in Git. Teammates clone and run. Production matches local. Done.

The full video walkthrough is on YouTube and the Trelly repo is on GitHub.