Skip to main content

Command Palette

Search for a command to run...

Deploy a Supabase-Compatible Backend as a Single 58 MB Binary with Tinbase

Updated
•6 min read•View as Markdown

Running Supabase locally means pulling down a 12-container Docker stack that takes up over 2 GB on disk. That's fine on a powerful laptop. It's painful on a $5 VPS, a CI runner, a preview environment or a demo box.

Tinbase takes a different approach. It serves the same APIs that supabase-js expects (REST, Auth, Storage, Realtime and Edge Functions) from one process, backed by real Postgres 17. It can also be compiled into a single executable with no Node, npm or Docker required on the target machine.

This tutorial covers building that binary, running it on a server and putting it behind HTTPS. It also covers when you shouldn't do this yet.

⚠️ Status check: Tinbase is currently alpha (v0.19.0 at the time of writing). The project describes itself as not production-ready yet. It's a good fit for local dev, prototypes, preview environments and internal tools. Don't run your paying customers on it yet.


Why a single binary matters

Here's how the footprints compare, using the project's own published benchmarks (Apple Silicon, macOS 15):

Tinbase (binary) Supabase local
Install size ~92 MB (58 MB binary + Postgres) ~2,291 MB + Docker
Memory at boot ~49 MB ~1,441 MB
Memory under load ~66 MB ~1,626 MB
Processes 2 12 containers

Deployment comes down to copying one file and running it. Your app code doesn't change, because the official supabase-js SDK talks to Tinbase unmodified.


How it works

Every Tinbase service is a pure (Request) => Response fetch handler:

  • /rest/v1 is PostgREST-compatible (filters, embeds, RPC)
  • /auth/v1 is GoTrue-compatible (email/password, OAuth with PKCE, magic links, MFA)
  • /storage/v1 covers buckets, signed URLs and TUS uploads
  • Realtime uses the Phoenix WebSocket protocol (postgres_changes, broadcast, presence)
  • /functions/v1 runs Edge Functions in-process
  • /_/ serves a Studio-style dashboard

All of these sit on a swappable database engine. The binary defaults to the native engine, which is embedded Postgres 17 over a private unix socket. Row Level Security works exactly as it does on hosted Supabase, so auth.uid() policies don't need changes.


Step 1: Build the binary

The binary is built from source with Bun:

git clone https://github.com/tinbase/tinbase.git
cd tinbase
npm install
npm run build:binary   # requires bun; outputs dist-bin/tinbase (~58 MB)

Build on the same OS and CPU architecture as your target server (for example, Linux x64 for most VPSes). The native engine supports macOS and Linux on x64 and arm64.


Step 2: Prepare your project

Tinbase reads the same project layout as the Supabase CLI:

my-app/
└── supabase/
    ├── migrations/
    │   └── 20260101000000_init.sql
    ├── functions/
    │   └── hello/index.ts
    ├── seed.sql
    └── config.toml        # optional: OAuth providers etc.

A sample migration with RLS:

-- supabase/migrations/20260101000000_init.sql
create table todos (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid(),
  title text not null,
  done boolean not null default false
);

alter table todos enable row level security;

create policy "own rows" on todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

Migrations and seed.sql are applied on boot and tracked in the same table the Supabase CLI uses. If you outgrow Tinbase, you can push the same files to hosted Supabase.


Step 3: Copy it to the server and run it

scp dist-bin/tinbase user@your-server:/opt/tinbase/tinbase
scp -r supabase user@your-server:/opt/tinbase/app/supabase

On the server:

export TINBASE_JWT_SECRET="$(openssl rand -hex 32)"

/opt/tinbase/tinbase start \
  --dir /opt/tinbase/app \
  --data-dir /var/lib/tinbase/db \
  --port 54321

Three things matter here:

  1. Set TINBASE_JWT_SECRET. The default secret is in Tinbase's public source code, so anyone could forge tokens against an instance that uses it.
  2. Use a persistent --data-dir. The default is <dir>/.tinbase/db. Put it somewhere you back up.
  3. Allow one network call on first boot. The binary downloads about 12 MB of Postgres binaries on first run and caches them in ~/.cache/tinbase. Air-gapped servers need that cache copied over in advance.

Print your keys:

/opt/tinbase/tinbase keys --dir /opt/tinbase/app

Step 4: Keep it running with systemd

# /etc/systemd/system/tinbase.service
[Unit]
Description=tinbase backend
After=network.target

[Service]
User=tinbase
Environment=TINBASE_JWT_SECRET=replace-with-your-secret
ExecStart=/opt/tinbase/tinbase start --dir /opt/tinbase/app --data-dir /var/lib/tinbase/db --port 54321
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tinbase

If you deploy on a platform like Render or Heroku instead, Tinbase reads the injected PORT variable automatically.


Step 5: Put HTTPS in front of it

Keep Tinbase bound to localhost and let a reverse proxy handle TLS. With Caddy:

api.example.com {
    reverse_proxy 127.0.0.1:54321
}

Caddy proxies WebSocket upgrades by default, so Realtime works without extra config.


Step 6: Point your app at it

import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'

const supabase = createClient<Database>(
  'https://api.example.com',
  process.env.EXPO_PUBLIC_TINBASE_ANON_KEY!
)

const { data } = await supabase
  .from('todos')
  .select('*')
  .eq('done', false)

To generate typed clients, run tinbase gen types typescript > database.types.ts.

That works the same in a React Native or Expo app. If you're building one from scratch, RapidNative generates React Native apps from a prompt, and Tinbase came out of the same team's work on running a full backend in the browser and on phones.


When this setup fits

Good fit Not yet
PR preview environments Production apps with real users
Prototypes and hackathon demos Phone/SMS auth or SSO/SAML (planned)
Internal tools on a small VPS pgvector / vector search (planned)
CI test backends (boots in ~2 s) Real image transformations (currently a no-op)
Offline or edge demos Teams that need managed backups and HA

Remember that cron jobs and webhooks only run while the process is up, and missed runs aren't caught up afterwards.


Wrapping up

The pitch is simple: one file, real Postgres and an unchanged supabase-js. For previews, prototypes and internal tools, replacing a 2 GB Docker stack with a 58 MB binary is a clear win. For production, watch the Tinbase docs as it moves out of alpha.

Have you tried running Supabase-style backends outside Docker? Share your setup in the comments.