Deploy a Supabase-Compatible Backend as a Single 58 MB Binary with Tinbase
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/v1is PostgREST-compatible (filters, embeds, RPC)/auth/v1is GoTrue-compatible (email/password, OAuth with PKCE, magic links, MFA)/storage/v1covers buckets, signed URLs and TUS uploads- Realtime uses the Phoenix WebSocket protocol (
postgres_changes, broadcast, presence) /functions/v1runs 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:
- 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. - Use a persistent
--data-dir. The default is<dir>/.tinbase/db. Put it somewhere you back up. - 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.

