The open-source backend platform that runs in two containers and 124 MB.
Postgres, authentication, an auto-generated REST API, file storage, realtime, edge functions and an admin studio — self-hosted, Apache-2.0, no vendor account, no telemetry.
Every self-hosted backend platform asks you to run a fleet. Supabase's own compose file starts fourteen services: an API gateway, an auth server, a REST server, a realtime server, a storage server, an image proxy, a metadata service, a studio, an edge runtime, an analytics service, a log shipper, a connection pooler, object storage and Postgres. Each is a process to supervise, a version to track and a way for your Saturday to end badly.
Baselyra is the same product surface in one Node process and one Postgres. Not a subset — auth with third-party sign-in and phone OTP, an auto-generated REST API with row level security, storage with signed URLs, realtime with per-subscriber policy checks, edge functions, webhooks, scheduled jobs, multi-project isolation, and a studio. The numbers above are measured with scripts/footprint.sh, not estimated.
git clone https://github.com/DeveloperSarim/baselyra.git && cd baselyra
./scripts/setup.sh # generates .env with fresh secrets, prints your admin password
docker compose up -d --build # ~2s cold boot after the image is builtOpen http://localhost:3130. That is the whole installation.
import { createClient } from '@baselyra/client'
const bl = createClient('http://localhost:3130', ANON_KEY)
await bl.auth.signUp({ email: 'ada@example.com', password: '••••••••' })
const { data } = await bl.from('posts').select('id,title,author:users(name)').eq('published', true)
bl.channel('room:42')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, render)
.subscribe()Measured on the same 4-core / 16 GB VPS, idle, after boot.
| Baselyra | Supabase (self-hosted) | Appwrite | Firebase | |
|---|---|---|---|---|
| Containers | 2 | 14 | 8 | — (managed only) |
| Resident memory | 124 MB 1 | 2.5 GB 2 | not measured | — |
| Cold boot to healthy | 1.9 s | not measured | not measured | — |
| Runtime dependencies | 8 | — | — | — |
| Self-hostable | ✅ | ✅ | ✅ | ❌ |
| Database you can leave with | ✅ Postgres | ✅ Postgres | ❌ proprietary | |
| Authorisation model | Postgres RLS | Postgres RLS | App-layer permissions | Security rules DSL |
| License | Apache-2.0 | Apache-2.0 | BSD-3 | Proprietary |
| Telemetry by default | none | opt-out | opt-out | always |
1 — idle, immediately after boot, via scripts/footprint.sh. 2 — a Supabase stack running on the same host, measured with docker stats; it was serving live traffic at the time, so this is not a like-for-like idle figure. Appwrite and Firebase numbers are omitted rather than estimated, because I have not measured them. Container counts are from each project's own compose file.
Where the others are ahead, plainly: Supabase has managed hosting, a far larger ecosystem, more auth providers, branching on managed projects and years of production hardening. Appwrite has a broader set of official SDKs and a mature console. Firebase has global edge infrastructure nobody self-hosts. If you want someone else to run it, run Supabase.
Baselyra is for the case where you want the whole thing on a €5 VPS, in a codebase you can read in an afternoon, with your data in a Postgres you can pg_dump and take anywhere.
Two databases, on purpose. Baselyra's own operating data — studio accounts, the project registry, the audit log, request metrics — lives in a control database. Your application's data lives in a project database. Postgres cannot query across databases without FDW, so the SQL editor holding a service key still cannot read a studio password hash. That is a boundary, not a convention.
baselyra_control baselyra_app (one per project)
├── control.platform_users ├── auth.* your application's end users
├── control.projects ├── storage.* your buckets and objects
├── control.roles ├── baselyra.* this project's configuration
├── control.audit_log └── public.* your tables
└── control.request_stats
Each project carries its own JWT secret, so a leaked anon key for one project is worthless against another.
| Auto-generated REST API | Every table and view becomes an endpoint. 12 filter operators, embedded resources through foreign keys, bulk insert, upsert, Content-Range paging, RPC over Postgres functions. |
| Row level security | Authorisation is Postgres policies. The request runs as anon or authenticated with its JWT claims published as a setting; the application never filters rows for security. |
| SQL editor | Schema browser, query tabs, hand-written autocomplete, read-only by default, CSV/JSON export, error positions mapped to the offending line. |
| Migrations | Two ledgers, one per database. Idempotent, re-runnable, applied on boot. |
| Read replicas | Health- and lag-aware routing, with a read-your-own-write pin so you never serve a stale read to the caller who just wrote. |
| Branching | CREATE DATABASE ... TEMPLATE copies of a project for development. |
Buckets with MIME allow-lists and size limits · streaming uploads that become visible only once complete · HTTP Range and If-None-Match so video and images behave · HMAC signed URLs · local disk or any S3-compatible backend (S3, R2, B2, MinIO, Wasabi) with presigned GETs served directly by the object store · on-the-fly image transformation with a checksum-keyed cache.
| Realtime | Postgres changes over LISTEN/NOTIFY, plus broadcast and presence channels. Every row is re-read as the subscriber's own role before delivery, so RLS reaches realtime instead of stopping at the REST API. |
| Edge functions | Your JavaScript in a worker_threads worker with memory and wall-clock limits and an explicitly built global surface. Versioned, rollback-able, with encrypted per-function secrets and captured logs. No container per invocation. |
| Database webhooks | Queued in Postgres with FOR UPDATE SKIP LOCKED, HMAC-signed with a timestamp, exponential backoff, a dead-letter queue and replay. Refuses private address ranges by default — a webhook aimed at 169.254.169.254 is SSRF into cloud metadata. |
| Scheduled jobs | A hand-written cron parser with IANA timezones that handles both daylight-saving transitions correctly. Overruns skip rather than stack. |
| AI assistant | DeepSeek-backed. Ask your database a question in English; the generated SQL runs in a read-only transaction with a statement timeout. |
Studio with 20 capabilities across 10 areas and custom roles · request metering with hourly rollups · audit log · nightly pg_dump plus storage volume with a verified restore path · one-click import from five sources.
The parts most backends get wrong, and what Baselyra does instead.
Authorisation is in the database, not the application
Every user-facing query runs inside a transaction that has switched to the caller's Postgres role and published their JWT claims as request.jwt.claims. Policies decide what is visible. The application never adds a WHERE user_id = ... for security reasons — if a policy is wrong, Postgres says no.
Both settings are LOCAL, so COMMIT or ROLLBACK restores the pooled connection. An elevated role cannot leak into the next request on the same connection. This is verified in the test suite.
The SQL editor is not a superuser
An admin console that runs as the Postgres superuser is remote code execution on the host: COPY ... FROM PROGRAM executes shell commands and pg_read_file reads anything the server user can. Baselyra's SQL editor runs as a dedicated NOLOGIN role that has BYPASSRLS — so the console is as powerful as a console should be — but is not a superuser and holds none of pg_read_server_files, pg_write_server_files or pg_execute_server_program.
There is deliberately no statement blocklist. A half-working SQL parser gives false confidence; the role is the boundary.
Studio accounts are not application users
Who may open the console lives in control.platform_users, in a different database from auth.users. Your application's users cannot reach the console however their row is configured, and a studio password hash is not reachable from the project's SQL editor. A studio token carries a typ: "platform" marker that only the login endpoint can mint.
Tokens, secrets and hashes
- HS256 with the algorithm pinned — no
none, no RS/HS confusion — and constant-time signature comparison. - One-time tokens (password reset, magic link, OTP) are stored only as SHA-256 hashes. A leaked database yields no working reset links.
- Passwords use scrypt with parameters embedded in the stored hash. Migrated bcrypt hashes are verified through pgcrypto and transparently re-hashed on first sign-in, so importing users never forces a password reset.
- Per-function secrets are AES-256-GCM encrypted with a key derived from the project's JWT secret.
- The service key never appears in a webview, a log line, an audit entry or an error message.
Input handling
- Every SQL value is a bound parameter. Identifiers are validated against the Postgres catalog or a strict regex and quoted; there is no string interpolation into SQL anywhere.
- Storage keys pass one containment check that rejects traversal, absolute paths, backslashes and NUL bytes, then asserts the resolved path is still inside the bucket root.
- OAuth
redirect_tois validated against an allow-list. An open redirect here hands the session to whoever asked. - Unfiltered
PATCHandDELETEon the REST API are refused unless explicitly opted into, because an unfiltered delete that empties a table is the most common self-inflicted disaster with this kind of API. - Rate limiting per IP and per account, with lockout counted on failures so one user's password cannot be guessed from many addresses.
What is not protected
No WAF, no DDoS protection, no intrusion detection — put a reverse proxy and a CDN in front. No secrets manager; .env is a file on your disk. No SOC 2, no compliance certifications. Full disclosure lives in docs/security.md.
|
JavaScript / TypeScript — zero dependencies, runs in browsers, Node, Deno, Bun and React Native. const { data, error } = await bl
.from('posts')
.select('id, title, author:users(name)')
.eq('published', true)
.order('created_at', { ascending: false })
.limit(20) |
Dart / Flutter final posts = await bl
.from('posts')
.select('id,title')
.eq('published', true)
.limit(20); |
|
Also: PHP, Python, Swift, Kotlin and plain cURL — every one documented with runnable examples generated from your live schema in the Studio's API reference. Any HTTP client works; the API is REST, not a protocol. | |
Data calls resolve to { data, error } and never reject, so a failed query does not need a try/catch around every call site.
One command from wherever you are now.
| Source | What comes across |
|---|---|
| Supabase (self-hosted or cloud) | Schema, data, RLS policies, users with their bcrypt passwords intact, storage objects |
| Appwrite | Collections as tables, documents as rows, users (argon2 hashes cannot be carried — those users are flagged for password recovery, and you are told) |
| Firebase | Firestore collections mapped to Postgres types, auth users, storage |
| PostgreSQL | Anything — this is the generic path |
| SQL dump | Parsed rather than executed, so selection and replace-mode still apply |
Tables stream through a server-side cursor in 1000-row batches, so a ten-million-row table does not become ten million rows of RSS. Constraints and indexes are applied after the data loads. Imported tables arrive with RLS enabled and a warning if a policy could not be translated — data you just moved should not land world-readable.
Progress streams over SSE with per-table row counts, and cancelling rolls back the table in flight while keeping the ones already finished.
A dense, keyboard-first admin console served by the same process at /.
Table editor with inline editing and foreign-key navigation · SQL editor with a schema browser and autocomplete · a policy editor with templates for the four policies people actually need · user management · a file browser · a realtime inspector · an API reference generated from your real columns · email template editing with a sandboxed preview · request traffic and usage · team roles as a capability matrix.
Manage the database without leaving the editor. Opens in the sidebar, the bottom panel or a full editor tab — the same application, laid out for the space it is given.
Schema explorer · data grid with inline editing · SQL runner with results, diagnostics on the offending line and CSV/JSON export · storage browser with drag-and-drop upload · user management · realtime inspector · API snippets · TypeScript type generation from your live schema.
Built against the stable API surface only and published to Open VSX, so it installs in Cursor, Antigravity and other VS Code forks, not just VS Code. The service key is held by the extension host and never enters a webview.
Extensions → Install from VSIX → vscode/baselyra.vsix
./scripts/setup.sh # secrets
docker compose up -d --build # run
./scripts/smoke.sh https://your.domain # verify end to end
./scripts/backup.sh # pg_dump + storage volume, nightly via cron
./scripts/restore.sh <db.dump> <storage.tar.gz>
./scripts/footprint.sh # reproduce the numbers in this READMEReverse-proxy configs for nginx and Apache are in deploy/, with the WebSocket upgrade rules spelled out — realtime silently failing behind a proxy that drops Upgrade headers is the single most common self-hosting complaint, and both configs handle it.
Full guide: docs/self-hosting.md.
| Status | |
|---|---|
| Website deployments — build and host static sites and SPAs directly from Baselyra, with custom domains, automatic TLS and atomic rollbacks, so your frontend and backend live in one place | 🔨 In progress |
| Multi-project switching in the Studio | 🔨 Registry, per-project databases and per-project secrets are shipped; project creation from the UI is landing |
| Official Dart, Python and Swift SDK packages | 📋 Planned — the REST API is stable and documented today |
| TOTP two-factor authentication | 📋 Planned |
| SAML / enterprise SSO | 📋 Planned |
| Point-in-time recovery | 📋 Planned |
| Managed hosting | ❌ Not planned — self-hosting is the product |
| Tests | 301 across 25 files, no mocks of Postgres behaviour |
| HTTP routes | 78 |
| Migrations | 12, idempotent, two ledgers |
| Runtime dependencies | 8 server · 3 studio · 0 SDK |
| Documentation | 30 pages, with llms.txt for language models |
| TypeScript | strict + noUncheckedIndexedAccess, zero errors |
Is this production-ready?
It runs in production on the maintainer's infrastructure. It is version 0.x: the API is stable and versioned, migrations are idempotent and reversible via backups, and there are 301 tests — but it has not had years of adversarial traffic. Read docs/security.md, run scripts/smoke.sh against your instance, and decide for your own risk tolerance.
How is this only two containers when Supabase needs fifteen?
Most of those fifteen exist because Supabase's components were written independently, in different languages, by different teams — Kong in Lua, GoTrue in Go, PostgREST in Haskell, Realtime in Elixir. Each needs its own process. Baselyra implements the same surfaces as modules in one TypeScript process, and leans on Postgres for the things Postgres is already good at: authorisation via RLS, change notification via LISTEN/NOTIFY, queuing via SKIP LOCKED. Fewer moving parts, not fewer features.
Can I use my existing Postgres?
Yes. Point DATABASE_URL at it and run the migrations. Baselyra creates the auth, storage and baselyra schemas and leaves public to you.
What happens to my data if I want to leave?
pg_dump your database and walk away. There is no proprietary format, no lock-in layer and no export API to beg for — it is plain Postgres, and your tables are your tables.
Do I need to know Row Level Security?
For a real application, yes, and that is a feature — it is the same skill that secures any Postgres. The Studio's policy editor ships templates for the common cases, warns loudly about tables in public with RLS off, and docs/row-level-security.md works through five complete policy sets including a chat application.
Does it scale?
Vertically, well — it is Postgres with a thin process in front. Horizontally, you can run several app containers against one database and add read replicas. What it does not do is shard, fail over automatically, or run multi-region. If you need that, you need a database team, not a BaaS.
Why is the SDK dependency-free?
Because fetch and WebSocket are in every runtime that matters now. A client library that pulls in a tree of transitive dependencies is a supply-chain surface in your users' browsers for no benefit.
Is there telemetry?
None. No analytics, no phone-home, no update check. The only outbound requests Baselyra makes are the ones you configure: your SMTP relay, your SMS provider, your OAuth providers, DeepSeek if you enable it, and your own webhooks.
| Getting started | Install, first table, first query |
| REST API · Filtering | Every operator, header and response shape |
| Row level security | The model, plus five complete worked policy sets |
| Authentication · OAuth · Phone OTP | Every sign-in path |
| Storage · Realtime | Files and live data |
| Importing | Leaving Supabase, Appwrite or Firebase |
| Self-hosting · Security | Running it for real |
Language models: llms.txt and llms-full.txt carry the whole documentation set as plain text.
Issues and pull requests are welcome. Read CONTRIBUTING.md first — it covers the architecture, the test strategy and the two rules that keep this project what it is:
- No new runtime dependencies without a case for why the standard library cannot do it.
- Authorisation belongs in Postgres. A pull request that filters rows in TypeScript for security reasons will be asked to write a policy instead.
npm install && npm test # 301 tests, no database required
./scripts/smoke.sh <url> # end-to-end against a running instanceApache-2.0 — use it commercially, modify it, self-host it, no strings.
Built by DeveloperSarim
Keywords · self-hosted backend as a service · Supabase alternative · Appwrite alternative · Firebase alternative · open source BaaS · Postgres row level security · realtime WebSocket API · auto-generated REST API · edge functions · self-hosted authentication · Docker · TypeScript