Keel: a self-hosted, Supabase-compatible backend
A backend platform for my home server that speaks Supabase's APIs: Postgres, REST, auth, file storage, realtime and edge functions, with one isolated database per app and a web dashboard to run it all. Apps use the standard supabase-js client unchanged, and this website's content is served from it.
Why I built it
I like Supabase's developer experience, but I didn't want every side project to depend on a hosted free tier that pauses, has size limits and keeps my data on someone else's servers. Keel gives my home server the same building blocks: apps keep using the standard @supabase/supabase-js client and simply point it at https://<server>/<project>.
Every app is a project with its own Postgres database, API keys, JWT secret, users, storage buckets and URL, so a bug or a leaked key in one app can't reach another.

How a request flows
From supabase-js call to database row
- 01ClientAn app calls createClient('https://server/<project>', anonKey), exactly as it would with Supabase
- 02GatewayRoutes /<project>/<service>/v1, checks the key against that project's own JWT secret, handles CORS and WebSockets
- 03Project registryIn-memory map of projects to databases and secrets, kept fresh with Postgres LISTEN/NOTIFY
- 04ServicePostgREST, auth, storage (+ imgproxy), realtime or a Deno function
- 05PostgresRuns as the caller's role so row level security applies; one database per project
- 06Realtime fan-outLogical replication is decoded, checked against each subscriber's RLS policies, then pushed over WebSocket
What's inside
- Gateway. Parses
/<project>/<service>/v1, verifies the API key against that project's secret (and itsrefclaim, so a key from one project fails on another), adds CORS and proxies WebSockets. Unknown projects get the same answer as a wrong key, so project names can't be discovered. - REST. Real PostgREST, one supervised process per project, so queries run as the caller's role and row level security applies.
- Auth. My own TypeScript service following the GoTrue API: email and password, magic links and OTP, SMS, TOTP and SMS multi-factor, GitHub and Google OAuth with PKCE, and an admin API.
- Storage. Buckets, signed URLs, on-the-fly image resizing through imgproxy, and resumable TUS uploads.
- Realtime. Broadcast, presence and
postgres_changes, fed by logical replication through a custom pgoutput decoder and filtered against each subscriber's RLS policies before anything is pushed. - Edge functions. One sandboxed Deno process per function, started on demand and stopped when idle, with network access to internal hosts denied and file access limited to the function's own folder.
- Studio. A React dashboard with a schema canvas, SQL editor, users, files, functions, logs, scheduled
pg_dumpbackups, container restarts and saved per-project default data.

Security by default
New tables in public get row level security switched on automatically by an event trigger. Services connect as limited roles and only the migrator and Studio hold the superuser password. Every container drops all Linux capabilities and runs with no-new-privileges, and Studio only talks to Docker through an allow-list proxy that can list, read logs and restart. Studio itself has multiple accounts, server-side sessions, rate-limited sign-in and TOTP, and sign-ins from the internet require two-factor authentication by default.
Testing and shipping
A GitHub Actions pipeline typechecks, runs the unit tests, builds both images, boots the whole stack and runs end-to-end tests against it with the real supabase-js client, then publishes multi-architecture images. Installation is a single script, with an installer for ZimaOS and optional Cloudflare Tunnel access.
Source: docker-compose.yml, server/src, e2e/ and .github/workflows in the Keel repository
What's next
Keel is version 0.1. Its own compatibility table lists what isn't there yet, such as SAML SSO, passkeys, an S3-compatible API, GraphQL and branching. It serves plain HTTP, so TLS comes from a reverse proxy or the tunnel.