Self-hosted backend-as-a-service · Apache-2.0
Two containers,not fifteen.
Baselyra is Postgres with row level security, an auto-generated REST API, auth, file storage, realtime and an admin Studio — running as one Node process next to one Postgres container, in a few hundred megabytes on a VPS you already pay for.
# three lines, then the Studio is up
git clone https://github.com/baselyra/baselyra.git
cd baselyra && ./scripts/setup.sh
docker compose up -d --build
setup.sh writes .env with a fresh JWT secret, a
Postgres password and an admin password, and prints the credentials once. The console comes
up on 127.0.0.1:3130.
2
containers, app and Postgres
8
runtime npm dependencies
259
tests, most needing no database
Apache-2.0
no telemetry, no vendor account
Every number on this site comes from the repository. There are no user counts and no testimonials here because there is nothing honest to put in their place.
Architecture
The part you were going to spend a weekend operating
A self-hosted Supabase or Appwrite is a fleet: a gateway, an auth service, a REST service, a realtime service, a storage service, an image proxy, a metadata service, a log pipeline, a function runtime, a pooler, a cache, a dashboard and a database. Each one is a process, an image, a config file and an upgrade. Baselyra is one Fastify process serving every prefix, and one Postgres holding two databases.
What fifteen containers buy you that two do not
This is the honest trade, and it is a real one:
- Independent scaling. PostgREST and the realtime server scale apart from each other. Here, one process scales or nothing does.
- Connection pooling at scale. A pooler in front of
Postgres survives traffic a single pool will not. Baselyra has one
pgpool. - Read replicas, failover and branching. None of it exists
here. One Postgres, backed up with
pg_dumpand a volume copy. - Swap one part. A fleet lets you upgrade the auth service alone. Baselyra upgrades as one image, which is simpler and also less granular.
- A managed tier. There is no hosted Baselyra. Running it is your job.
What two containers buy you back
- It fits. Roughly 400–600 MB at idle, so a 1 GB VPS runs a real production app.
- One log stream. When something breaks there is one process to read, not a guess about which of thirteen owns the request.
- One upgrade.
docker compose pull && docker compose up -d, not a version matrix across services that must agree. - One authorisation model. REST, storage and realtime all reach the database through the same transaction, so the same policies apply to all three.
- A surface you can read. Eight runtime dependencies, no ORM, no query builder library, no Redis, no broker, no sidecar.
Features
What is actually in the box
Each card links to the documentation page for that feature in the repository — the same page a contributor reads, not a marketing summary of it.
Auto-generated REST API
Every table, view and function in public, the moment it exists. Filters, ordering, pagination, upserts, RPC and one level of embedded resources — a PostgREST-compatible subset, so the client patterns you already know transfer.
docs/rest-api.md
Row level security decides
The request runs as anon or authenticated with the verified JWT claims published to Postgres. Policies do the rest. There is no authorisation logic in JavaScript to get wrong.
docs/row-level-security.md
Auth with rotating sessions
Email and password, magic links, one-time codes, recovery, email change and invitations. Refresh tokens rotate and reuse is detected. Failed attempts are throttled per account, not only per IP.
docs/auth.md
File storage with signed URLs
Buckets with MIME allow-lists and size limits, Range requests, ETag and 304 handling, signed URLs. Policies on storage.objects guard files exactly as they guard a table.
docs/storage.md
Realtime that re-checks RLS
One WebSocket carrying change feeds, broadcast and presence. Every changed row is re-read as the subscriber’s own Postgres role before delivery, so a policy that hides a row hides the event.
docs/realtime.md
One-click import
Supabase, plain Postgres, Appwrite, Firebase or a pg_dump file. Supabase and Postgres bcrypt hashes are carried across verbatim, so your users never see a password reset.
docs/importing.md
An optional AI assistant
DeepSeek, off unless you set a key: natural language to SQL, query explanation, ask-your-database, and a chat relay your app’s users can call without the API key ever reaching a browser.
docs/ai.md
Self-hosting, documented properly
Compose, nginx and Apache vhosts, TLS with certbot, backups and restores, upgrades, sizing and a security checklist. The reverse-proxy rule that breaks WebSockets is called out by name.
docs/self-hosting.md
Clients and frameworks
A zero-dependency JS/TS client, plus single-file clients for Dart and PHP and worked guides for Next.js, Vite, Angular, React Native, Flutter and Python.
docs/integrations.md
Not in the box: edge functions, S3 storage, image transforms, read replicas, a second project on one instance. What is landing, and what is not planned
The client
One session, one socket, four languages
The JavaScript client ships as a zero-dependency package. Dart and PHP are single files you
copy out of the documentation, and there is a working copy of each in
examples/. Everything
is plain HTTP and one WebSocket underneath, so a language with neither still gets the whole
API with curl.
import { createClient } from '@baselyra/client';
const bl = createClient('https://api.example.com', ANON_KEY);
// A session that renews itself; the refresh token rotates on every use.
await bl.auth.signInWithPassword({ email, password });
// Errors arrive next to the data. No call here ever rejects for an HTTP status.
const { data, error } = await bl
.from('posts')
.select('id, title, author:profiles(name)')
.eq('published', true)
.order('created_at', { ascending: false })
.limit(20);
// The row is re-read as your role before it is delivered, so this feed
// can only ever carry rows your policies already let you SELECT.
const channel = bl.channel('public:posts');
channel.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts' },
({ new: row }) => render(row));
await channel.subscribe();
await bl.storage.from('avatars').upload('me.png', file);
Runs unchanged in browsers, Node 22, Deno, Bun and React Native.
fetch and WebSocket are the only platform features it needs.
Client reference
final baselyra = BaselyraClient(
url: 'https://api.example.com',
anonKey: const String.fromEnvironment('BASELYRA_ANON_KEY'),
tokenStore: PrefsTokenStore(),
);
await baselyra.auth.restore();
await baselyra.auth.signInWithPassword(email: email, password: password);
// Failures throw BaselyraException — Dart's error channel is exceptions,
// so a widget's call site does not have to carry a branch it never wanted.
final posts = await baselyra
.from('posts')
.select('id,title,author:profiles(name)')
.eq('published', true)
.order('created_at', ascending: false)
.limit(20)
.get();
final channel = baselyra.channel('public:posts');
channel.changes.listen((change) => render(change.newRow));
await channel.subscribe();
await baselyra.storage.from('avatars').upload('me.png', bytes);
There is no published Dart package. This is one file
— examples/flutter/lib/baselyra.dart — needing only
http and web_socket_channel.
Flutter guide
require __DIR__ . '/baselyra.php';
use Baselyra\Client;
// The anon key alone: every query runs as the `anon` Postgres role.
$baselyra = new Client('https://api.example.com', $_ENV['BASELYRA_ANON_KEY']);
// Act as the signed-in user. RLS applies exactly as it would in a browser.
$session = $baselyra->signIn($email, $password);
$ada = $baselyra->withToken($session['access_token']);
$posts = $ada->from('posts')
->select('id,title,author:profiles(name)')
->eq('published', true)
->order('created_at', ascending: false)
->limit(20)
->get();
$baselyra->upload('avatars', 'me.png', $bytes, 'image/png');
PHP 8.1+, ext-curl and ext-json, nothing else.
The client is immutable, so a request handler cannot leave a shared instance authenticated
as the last visitor. examples/php/baselyra.php
# The anon key is public and belongs in your frontend. It names a Postgres
# role; the policies decide what that role may read and write.
curl 'https://api.example.com/rest/v1/posts?select=id,title,author:profiles(name)&published=eq.true&order=created_at.desc&limit=20' \
-H "apikey: $ANON_KEY" \
-H "authorization: Bearer $ACCESS_TOKEN"
# Sign in
curl -X POST 'https://api.example.com/auth/v1/token?grant_type=password' \
-H "apikey: $ANON_KEY" -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"…"}'
# Upsert, and ask for the rows back rather than a 204
curl -X POST 'https://api.example.com/rest/v1/posts' \
-H "apikey: $ANON_KEY" -H "authorization: Bearer $ACCESS_TOKEN" \
-H 'content-type: application/json' \
-H 'Prefer: resolution=merge-duplicates,return=representation' \
-d '{"id":1,"title":"Hello"}'
Errors are always
{ "error": { "code", "message", "details" } }, with the HTTP status matching.
REST reference
Security model
Postgres decides, not the route handler
Every user-facing query is wrapped in a transaction that sets the role and publishes the
verified JWT claims as request.jwt.claims, both LOCAL to that
transaction. From there the database is the only thing deciding what a request may see —
through REST, through the storage API and through a realtime subscription alike, because all
three go through the same code path.
No token, or the anon key on its own. Ships in your frontend. Sees exactly what a policy
grants to anon, which by default is nothing.
A signed-in user’s access token. auth.uid() is their id inside every
policy, so “their own rows” is one using clause.
BYPASSRLS. Legitimate in trusted server code that has no user to act as. Never
in anything a browser or a mobile binary can read.
-- Without this, a table in public is world-writable.
alter table public.posts enable row level security;
-- Anyone may read what is published.
create policy posts_read_published on public.posts
for select to anon, authenticated
using (published);
-- An author always sees their own drafts.
create policy posts_read_own on public.posts
for select to authenticated
using (author_id = auth.uid());
-- And may only write rows that are theirs. The `with check`
-- is what stops an update handing the row to someone else.
create policy posts_write_own on public.posts
for all to authenticated
using (author_id = auth.uid())
with check (author_id = auth.uid());
public is
readable and writable by anyone holding the anon key until you enable row level security and
write a policy. Read
this before you ship
control.platform_users, in a different database that a project
connection cannot reach — not even holding the service key. Postgres has no
cross-database queries without FDW, which makes it a boundary rather than a convention.
The Studio
An admin console, served by the same process
At /: a data grid with inline editing, a SQL editor with schema-aware
autocompletion, user management, a file browser, a realtime inspector, a per-table API
reference, the email template editor, the import wizard, the audit log and settings. Same
origin, same process, no second deployment.
Drawn from STUDIO.md and the Studio’s own components, in
the Studio’s own palette. Or just open
the real one
Migrating
Bring the project you already have
The Studio’s Import page connects to a Supabase project, a plain Postgres database,
Appwrite, Firebase or a pg_dump file, shows you what is in it, and copies what you
tick while streaming progress. The headline is passwords: Supabase and Postgres bcrypt hashes
move across verbatim, so nobody has to reset anything.
| Source | Tables | Users | Passwords | Files | Policies |
|---|---|---|---|---|---|
| Supabase | types, keys, indexes | yes | preserved (bcrypt) | with URL + service key | translated where possible |
| Postgres | types, keys, indexes | if auth.users exists | preserved if bcrypt | n/a | translated where possible |
| Appwrite | collections → tables | yes | lost (argon2) | yes | none to translate |
| Firebase | collections → tables, sampled | yes | lost (keyed scrypt) | with storageBucket | none to translate |
| SQL dump | CREATE TABLE + COPY | if it is in the dump | preserved if bcrypt | n/a | none to translate |
Appwrite never returns a password hash and Firebase’s scrypt is keyed with a signer Google holds, so neither can be verified afterwards. Those accounts arrive intact and must go through recovery — the inspect step says so before you start. The full migration guide
Get started
Clone it, run one script, and it is up.
No account, no waitlist, no credit card, no key to request. Apache-2.0, and the whole server is one process you can read in an afternoon.