Skip to content
Danube Labs

Running Several Supabase Projects on One Machine

A second local Supabase project collides on the same ports, and hand-offset ports drift away from your app config. See what the CLI now assigns and what it costs.

By Alexandru Secureanu8 min read
Two local Supabase stacks behind one stable entry point instead of fixed ports

Running more than one local Supabase project on one machine fails in a specific and predictable way, and it helps to see the mechanism before picking a workaround. The first project works. The second one either refuses to start because a port is already taken, or it starts on ports you chose by hand, and from then on two applications, two .env files and two sets of scripts have to agree about which number belongs to which project. When they stop agreeing, nothing crashes. The app connects to the other project's database, answers 200, and the mistake surfaces much later.

What a local Supabase project is made of

supabase init writes a supabase/ directory with a config.toml in it. supabase start brings up the local stack: "a local Postgres database, Auth, Storage, and other Supabase features", as the local development guide puts it. The stack runs in containers, so a Docker-compatible runtime is a prerequisite; the docs list Docker Desktop, OrbStack, Rancher Desktop and Podman. On Linux and on macOS with Apple silicon the experimental supabase stack commands can also run the services as plain processes, which we come back to later.

The services are reached through ports, and inside one project they are not one port per service. The CLI getting-started page shows a single gateway on 54321 with everything behind path prefixes: /rest/v1, /graphql/v1, /auth/v1, /storage/v1, /realtime/v1 and /functions/v1. Postgres listens on 54322, Studio on 54323, the mail UI on 54324.

The remaining defaults are in the CLI config reference:

  • api.port 54321, the gateway.
  • db.port 54322, and db.shadow_port 54320 for the shadow database.
  • studio.port 54323.
  • The mail section, inbucket in the config reference: web UI on 54324, SMTP on 54325, POP3 on 54326.
  • analytics.port 54327 and analytics.vector_port 54328.
  • db.pooler.port 54329 for the connection pooler.
  • edge_runtime.inspector_port 8083 for debugging Edge Functions.

The numbers themselves are not the interesting part. What matters is that every one of them is a fixed address something outside Supabase has to know: your app, your test runner, your seed script, a command in the README.

Why the second project collides

supabase init writes those same ports into every project's config.toml, and the file is meant to be committed. The local workflow guide lists it as "Local stack configuration (ports, auth settings, etc.)" and marks it as a file to commit. Two projects on one machine therefore ask for the same ports, and the local development guide states the result plainly: "With the default ports, supabase start runs one local project per machine."

That failure is at least loud. The troubleshooting section of the workflow guide names it, "Another local Supabase project is using the default ports", and the fix it offers is to run supabase stop in the other project's directory. That is fine when you switch between projects. It is useless when you need both at once, which is now ordinary: an API and its admin app, a monorepo with two services, or a few coding agents working in parallel worktrees.

Approach one: keep a list of ports

The cheapest approach is a table in the README saying project A is on 54321 and project B on 54421, or the same information kept in someone's head. It costs nothing to set up and almost nothing to run, until it is wrong. Ports written in config.toml are exact requests, so the moment two rows overlap, or a third project arrives, the start fails and someone edits the table. The table also has to be copied everywhere the address is used: the app's environment file, the migration scripts, the test setup, the curl commands in the runbook.

The command that makes this survivable is supabase status, which prints the URLs and keys of the running project. supabase status --env --output-format text writes them as a dotenv file, and you can rename the variables for your framework in the same call:

supabase status --env --override-name API_URL=NEXT_PUBLIC_SUPABASE_URL,ANON_KEY=NEXT_PUBLIC_SUPABASE_ANON_KEY

Using that instead of hand-written values removes drift inside one project. It does not tell you which project you are in.

Approach two: edit the ports in each config.toml

The obvious fix is to give each project its own block. Change api.port, db.port, studio.port and the rest in one of the two files, then restart with supabase stop and supabase start so the new values take effect. The multiple-projects guide lists every key that has to move: api.port; db.port and db.shadow_port; db.pooler.port; studio.port; the SMTP section's port, smtp_port and pop3_port; analytics.port and vector_port; and edge_runtime.inspector_port. Miss one and the project keeps requesting a port the other project holds.

The cost is that the offsets are now committed. A teammate who clones the repository inherits your port choices, and the values travel into CI, where a container may already use them. You also have to change every consumer: the app's URL, the database URL in the seed script, the pooler URL if you use it. This works, and it is where most teams start. It is a set of numbers maintained by hand in two places that cannot check each other.

Approach three: offset through environment variables

Ports can also come from the environment, for example SUPABASE_API_PORT and SUPABASE_DB_PORT. They are exact requests in the same way as the file, with one improvement: if another local project holds the port, the error names that project instead of the number. A collision becomes a two-minute fix rather than a hunt. It does not remove the bookkeeping, and it does not help on a machine where the variable is not set.

All three approaches share one weakness. The address is the project's identity. Change one end and not the other, and the app keeps working, against the wrong database.

What the CLI does now, and what it does not fix

Since CLI v2.119.0 there is a newer answer: the experimental supabase stack commands. With stack = true under [experimental] in config.toml, or SUPABASE_EXPERIMENTAL_STACK=1 in the environment, the top-level start, status and stop run the stack versions. A local project is identified by three things: the nearest directory containing supabase/config.toml, the current git branch, and a name passed with --stack. Different identities never share containers, processes, ports or data, so one app can run supabase start --stack dev and supabase start --stack test side by side.

The ports are the part that matters here. The CLI assigns them from the range 20000 to 32767 and keeps them stable across restarts. Fixed ports in config.toml override that, so an existing project has to remove them first: the guide says to run supabase stop, delete the keys and start again, and that from v2.121.0 the CLI moves the endpoints and keeps the data. The same guide calls the commands experimental, warns that their flags and output can change, and says the CLI compatibility promise does not cover them.

Two consequences follow. First, the number is now assigned rather than chosen. The docs promise stability, not a particular value, so 54321 stops being the answer even for the project that used to own it. Second, anything that needs the address should ask for it. supabase start --output-format json returns an endpoints object keyed by service and endpoint, each entry carrying a protocol, an address, a port and a URL. supabase stack list shows every local project on the machine, and supabase stack stop --all stops them in one go. For a team that is a real improvement. For a script that still hardcodes http://127.0.0.1:54321, it is a silent break.

There are limits worth knowing before you move. Database commands such as db and migration do not accept --stack, so they act on the default project for the current branch. Checking out another branch in the same directory creates a different local project, and the first one keeps running until you stop it. Renaming or moving the app directory creates a new project too, because the path is part of the identity. supabase stack destroy deletes data and takes no backup. None of this is a reason to avoid the commands. It is a reason to read the guide once.

The failure that actually costs time

Port collisions are annoying but visible. The expensive failure is a stale address. Suppose someone offsets a project to 54421 and points NEXT_PUBLIC_SUPABASE_URL there. Later the offsets change, or the project switches to the stack commands and gets a port in the 20000 range. The old value in .env.local still resolves, because another project now listens there. The app boots, the login screen renders, and supabase db reset runs against the wrong database. Nothing in the stack checks that the project you meant is the project you reached.

The habit that prevents it is cheap. After pointing an app at a local stack, run one query only the right project can answer: a row you seeded, a migration version, a table name. Read the port from supabase status instead of a note. If two people work on the same repository, have the app read its URL from the environment file the CLI writes, not from a value committed months ago.

A front door instead of a port

The other way out is to stop treating the port as the name. Run one small proxy on a fixed port, give each project a hostname or a path, and let the proxy forward to whatever port the project is on today. Applications then point at http://billing.localhost and never change, while the map from name to port lives in one place.

Our Supabase Local Manager is one implementation of that idea, in early access. A single Node and Express service provisions and manages many local Supabase stacks at once; each project reserves a block of 11 ports from the 60000 band; a path-routed front door listens on 6000 and a subdomain proxy on 9000. The Supabase CLI handles the basic database management, and the manager adds the services, APIs and frontend a full Supabase stack needs, so the whole stack runs locally from one place. It needs Docker. It is independent of Supabase, Inc., the source is private, and access is requested through GitHub.

How we choose

  • One project at a time: use the defaults. The second project is one supabase stop away.
  • Two or three projects, one developer: the experimental stack commands, with fixed ports removed, and supabase status as the only source of addresses.
  • Several services, several agents or a team: give each project a name and put a front door in front of the ports, so no application ever stores a number.

If you are wiring Supabase into a product and want the local setup to survive a second developer, our custom software work covers that kind of groundwork, and you can book a free intro call. We also wrote about the opposite choice, a site with no database at all, if you are still deciding how much backend you need.

Frequently asked questions

Why can I not run two Supabase projects locally at the same time?
Every project generated by supabase init writes the same ports into config.toml, and the file is meant to be committed. The CLI treats those values as exact requests, so the second project asks for ports the first one already holds. The Supabase docs say it directly: with the default ports, supabase start runs one local project per machine.
What ports does supabase start use by default?
The API gateway listens on 54321 and serves REST, GraphQL, Auth, Storage, Realtime and Functions under path prefixes. Postgres uses 54322, the shadow database 54320, Studio 54323 and the mail UI 54324. The config reference also lists SMTP on 54325, POP3 on 54326, analytics on 54327 and 54328, the pooler on 54329 and the Edge Functions inspector on 8083.
How do I run several local Supabase projects with the CLI?
On CLI v2.119.0 or later, enable the experimental stack commands by adding stack = true under [experimental] in config.toml. Then remove the fixed port keys, because the CLI assigns each project its own ports from 20000 to 32767. Start each project in its own directory or worktree, and read the addresses from supabase status instead of assuming them.
Are the supabase stack commands stable enough for daily work?
The Supabase docs call them experimental and say their flags and output can change between releases, and that the CLI compatibility promise does not cover them. Ports are assigned from 20000 to 32767 and kept stable across restarts. Data for stack projects is separate from anything started with the default supabase start, and enabling the setting does not copy the older database.
How do I free port 54321 from an old Supabase project?
Run supabase stop in the directory of the project that holds the port, which is the fix the Supabase troubleshooting guide gives for a port conflict. Stopping keeps the local database, so the other project can start. If you need both running, move one of them to its own port block or to the stack commands rather than stopping the other one each time.
How do I check which local Supabase project my app is talking to?
Run a query that only the right project can answer, such as a seeded row, a migration version or a table name that exists nowhere else. Read the port from supabase status rather than from a note, and write the app environment from supabase status --env. A stale URL still resolves if another project now listens on that port, so the connection succeeding proves nothing on its own.
Updated
Share

Related articles

  • 9 min read

    PostgreSQL Extensions Worth Knowing

    Extensions let PostgreSQL handle case-insensitive text, fuzzy search, UUIDs and spatial queries without application code. What each is for, when it is the wrong tool, and why your host decides what you can install.

    • Databases
    • Developer tools
  • 9 min read

    Backups You Have Actually Restored

    A backup is a claim and a restore is the evidence. Here is a routine a small team can keep: where copies live, how often they run, how to rehearse a restore on a scratch database, what to check, and how to record it.

    • Databases
    • Developer tools
  • 9 min read

    Self-Hosting a Database: Personal vs Production

    Running your own PostgreSQL is reasonable for a side project and a real commitment for a business. The software is the same; the duties are not.

    • Databases
    • Developer tools