Skip to content

Repository files navigation

Baselyra

Baselyra

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.

Live demo · Documentation · Website · Quickstart

Apache 2.0 301 tests passing 8 runtime dependencies 2 containers Postgres 17 Node 22 TypeScript strict


Measured resident memory: Baselyra 124 MB across 2 containers, a Supabase stack on the same host 2.5 GB across 14 containers


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.


Quickstart

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 built

Open 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()

Table of contents


Why Baselyra

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 ⚠️ MariaDB, proprietary schema ❌ 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.


Architecture

A typical stack runs fifteen services; Baselyra runs one Node process and Postgres

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.


Features

Database & API

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.

Authentication

Email & password scrypt via node:crypto, parameters stored in the hash so they can be raised without invalidating anyone.
Sessions Short-lived HS256 access tokens, opaque rotating refresh tokens. A replayed refresh token revokes the entire session family — theft detection, not just expiry.
Third-party sign-in Google · GitHub · LinkedIn · Facebook · Instagram · TikTok · Envato — PKCE where supported, one redirect URI for all seven.
Phone / SMS OTP Twilio, Vonage, MessageBird, AWS SNS, Plivo, or a generic webhook. SigV4 for SNS is implemented in-repo; no vendor SDK.
Magic links & email OTP With SMTP templates you can edit in the Studio.
No user enumeration /recover, /magiclink, /otp and /signup return the same response, in comparable time, whether or not the address exists.

Storage

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, functions and jobs

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.

Operations

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.


Security

The parts most backends get wrong, and what Baselyra does instead.

The request path: verify the token, switch the connection to the caller's role, publish the JWT claims, let policies decide

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_to is validated against an allow-list. An open redirect here hands the session to whoever asked.
  • Unfiltered PATCH and DELETE on 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.


Client libraries

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.


Migrating in

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.


Studio

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.

VS Code extension

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

Self-hosting

./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 README

Reverse-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.


Roadmap

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

Stats

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

FAQ

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.


Documentation

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.


Contributing

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:

  1. No new runtime dependencies without a case for why the standard library cannot do it.
  2. 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 instance

License

Apache-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

About

Open-source backend platform in two containers and 124 MB. Postgres with row level security, auto-generated REST API, auth with OAuth and phone OTP, storage, realtime, edge functions and an admin studio. Self-hosted alternative to Supabase, Appwrite and Firebase.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages