DEV Community

Cover image for Why Supabase local dev needs 2.3 GB of Docker — and how we got it to 58 MB
Russel Dsouza
Russel Dsouza

Posted on

Why Supabase local dev needs 2.3 GB of Docker — and how we got it to 58 MB

  • supabase start runs the production architecture on your laptop: 12 containers, 2,291 MB on disk, 1,626 MB RAM under load
  • Those services are mostly protocol translators — and protocols can be reimplemented
  • We rebuilt the surface as one process: pure (Request) ⇒ Response handlers on a swappable Postgres engine
  • Result: a 58 MB executable, ~2 s cold start, and the official supabase-js SDK passing 168/168 integration tests unchanged
  • Side effect: the same backend runs inside a browser tab

Part 1: Where the 2.3 GB goes

Run supabase start on a fresh machine and watch docker ps fill up. What you're looking at is Supabase's cloud architecture, faithfully reproduced locally — which is precisely the problem. Each box in their production diagram becomes a container on your laptop:

┌────────────────────────────────────────────────┐
│  supabase start                                │
│                                                │
│  kong          → API gateway / routing         │
│  postgrest     → REST API over Postgres        │
│  gotrue (auth) → JWTs, OAuth, magic links      │
│  realtime      → WebSockets (Elixir/Phoenix)   │
│  storage-api   → file storage over Postgres    │
│  imgproxy      → image transforms              │
│  edge-runtime  → Deno functions                │
│  studio        → dashboard (Next.js)           │
│  postgres-meta → introspection API for Studio  │
│  logflare      → log aggregation               │
│  vector        → log shipping                  │
│  db (postgres) → the actual database           │
└────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Each service ships as its own image with its own runtime — an Elixir VM here, a Deno runtime there, Node for Studio, Go for auth. None of it is waste in production: these are independently scalable services doing real jobs across a fleet.

But locally, every one of those runtimes is overhead for a single developer making requests from localhost. The measured cost:

Install footprint Memory under load
Supabase local (12 containers) 2,291 MB 1,626 MB
tinbase (single binary) 92 MB 66 MB
tinbase (native, via npx) 36 MB 100 MB

And that's before the Docker Desktop tax on macOS and Windows, or environments where Docker isn't available at all: CI sandboxes, cloud IDEs, school machines, a phone.

Part 2: The key observation — it's protocols all the way down

Here's what makes the whole thing tractable. Your app never talks to those containers directly. It talks to documented HTTP and WebSocket protocols through supabase-js:

  • /rest/v1 speaks the PostgREST grammar?select=*,author:users(name)&done=eq.false
  • /auth/v1 speaks GoTrue's API — signup, OTP, PKCE, refresh tokens
  • /storage/v1 speaks the Storage API
  • Realtime speaks the Phoenix channel protocol over WebSockets
  • /functions/v1 invokes edge functions

A protocol doesn't care what implements it. PostgREST is ~"take this URL grammar, compile it to SQL, run it with the caller's JWT claims applied." GoTrue is "manage users in auth.users, mint JWTs." These are translation layers over Postgres — and translation layers can be reimplemented in-process.

What you cannot fake is Postgres itself. RLS evaluation, auth.uid() in policies, jsonb operators, triggers, ON DELETE CASCADE — behavior differences there produce the worst kind of bug: works locally, breaks in production. So that became the design rule:

Reimplement the translation layers. Never reimplement Postgres.

Part 3: The architecture — one fetch handler

Every tinbase service is a pure function: (Request) ⇒ Response. No sockets owned, no ports assumed, no filesystem requirements baked in. The whole backend composes into a single handler:

supabase-js (unmodified)
        │
        ▼
one (Request) ⇒ Response handler
  ├─ /rest/v1      → PostgREST grammar → SQL
  ├─ /auth/v1      → GoTrue flows → auth schema
  ├─ /storage/v1   → Storage API
  ├─ Realtime      → Phoenix protocol
  ├─ /functions/v1 → in-process handlers
  └─ /_/           → Studio dashboard
        │
        ▼
DbEngine adapter
  ├─ native → embedded Postgres 17
  ├─ wasm   → PGlite (Postgres compiled to WASM)
  └─ pg-mem → pure JS, in-memory (subset)
Enter fullscreen mode Exit fullscreen mode

The REST layer parses the PostgREST grammar and compiles it to parameterized SQL — embedded resources become joins, filters become WHERE clauses — then executes with the request's JWT claims set, so your RLS policies run exactly as Postgres intends:

-- every request runs with claims applied, e.g.:
SET LOCAL request.jwt.claims = '{"sub":"<user-uuid>","role":"authenticated"}';
-- so this policy Just Works, locally and in prod:
CREATE POLICY "own todos" ON todos
  FOR SELECT USING (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

Realtime does the same per-subscriber: change events are filtered through RLS before delivery, so a user only receives events for rows they're allowed to see.

Things production Supabase does with Postgres extensions get reimplemented natively instead, because a single process can just... do them:

  • database webhooks — no pg_net
  • cron.schedule() — no pg_cron
  • a pgmq subset — no pgmq

Part 4: The consequences of "pure fetch handler"

This constraint looked academic. It turned out to be the most productive decision in the project.

In Node, the handler binds to a port and becomes an ordinary HTTP + WebSocket server:

npx tinbase start
# ~2 seconds → http://127.0.0.1:54321, Studio at /_/
Enter fullscreen mode Exit fullscreen mode

As a binary, it compiles to a single 58 MB executable — no Node, npm, or Docker on the target machine. That's the CI story: download one file, run it, test against real Postgres.

In a browser, you hand the same handler to supabase-js as a custom fetch:

import { createClient } from '@supabase/supabase-js'
import { createTinbase } from 'tinbase'

const tin = await createTinbase({ engine: 'wasm' })   // PGlite under the hood

const supabase = createClient('http://tinbase.local', ANON_KEY, {
  global: { fetch: tin.fetch }   // ← the entire backend is this function
})

// no server anywhere; Postgres is running inside the tab
await supabase.from('todos').insert({ title: 'hello from a browser tab' })
Enter fullscreen mode Exit fullscreen mode

Database included, running in-process. No server, no cloud. (Live demo: tinbase.dev/browser.)

We didn't set out to build a curiosity — this was the original requirement. tinbase came out of building RapidNative, where we needed a full dev stack — database, auth, storage, realtime — running in browsers and on phones with no VMs behind it. The single-process backend fell out of that constraint; replacing local Docker was the happy accident.

Part 5: How we know it's compatible

Claiming "Supabase-compatible" is easy. Our test is blunt: run the official supabase-js SDK's integration suite against tinbase, unmodified.

Current score: 168/168 passing, across both the native Postgres engine and the WASM engine. That covers the REST grammar (filters, embedded resources, RPC), the auth flows (email/password, anonymous, OTP, magic links, recovery, OAuth with PKCE), storage, and realtime's postgres_changes / broadcast / presence.

Migration compatibility is the other half. tinbase reads supabase/migrations/*.sql and seed.sql exactly like the Supabase CLI, tracked in the same table — so the exit path is: push the same files to hosted Supabase and keep going. Compatibility that doesn't include leaving isn't compatibility.

What this doesn't claim

tinbase is alpha (v0.10.0), and honesty is cheaper than a disappointed issue tracker:

  • Not production-ready. It's for local dev, prototypes, and embedded/browser use.
  • Not 100% of the surface. It covers the common paths (the test suite defines "common"); edges remain.
  • Not a Supabase replacement. It's the local half of a workflow that ends at hosted Supabase. Real Postgres in production is still the right call.
  • pg-mem is a subset. The pure-JS engine trades fidelity for zero-WASM environments; use native or PGlite when you can.

Try the numbers yourself

npx tinbase start
Enter fullscreen mode Exit fullscreen mode

Repo (MIT): github.com/tinbase/tinbase — the benchmark methodology is on the site if you want to reproduce the table above.

If you've solved local-Supabase pain a different way — Nix, compose tricks, pg_regress heroics — I genuinely want to hear it in the comments. And if you try the browser engine, tell me what you build with it.

Top comments (0)