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.

install
# 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.

baselyra.sarimtools.comBaselyrabaselyrapostgres:17OverviewBUILDDatabaseSQL EditorAuthenticationStorageRealtimeAPI DocsAI AssistantCONFIGUREImportEmailSettingsdatabase upOverviewRefreshAPI requests today1,284Errors today0Avg response9 msRealtime sockets3Requests per hour, last 24 hours00:00nowYour datapublic.posts48,210RLS on3 policiespublic.comments191,004RLS on2 policiespublic.profiles2,140RLS on4 policiespublic.rooms312RLS on2 policiespublic.messages884,061RLS on3 policiespublic.memberships5,102RLS on2 policiespublic.invoices1,878RLS on3 policiespublic.attachments9,340RLS on2 policiespublic.audit_notes40no policiesRecent activitysql.run14:02policy.create13:47table.create13:44realtime.enable12:51user.update12:30bucket.create11:58import.run11:20settings.update10:44sql.run10:12

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.

A TYPICAL SELF-HOSTED BaaS15 containersBASELYRA2 containerstraefikkonggotruepostgrestrealtimestorage-apiimgproxypostgres-metastudiovectoranalyticsfunctionssupavisorredispostgresone process each, one image each, one upgrade eachroughly 2–4 GB at idleNames taken from the compose files Supabase andAppwrite publish for self-hosting.baselyra-app-1node:22-alpineone Fastify process/auth/v1/rest/v1/storage/v1/realtime/v1/admin/v1/ai/v1/ (Studio)baselyra-db-1postgres:17-alpinetwo databases on one serverbaselyrapublic, auth, storagebaselyra_controlStudio accounts, auditone image, one `docker compose pull`, one log streamroughly 400–600 MB at idle
Container names taken from the compose files Supabase and Appwrite publish for self-hosting. Memory figures are idle, on the shipped settings.

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 pg pool.
  • Read replicas, failover and branching. None of it exists here. One Postgres, backed up with pg_dump and 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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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.

1 The request arrivesGET /rest/v1/posts?select=id,titleapikey: <anon key>authorization: Bearer <access token>2 The token is verifiedverifyJwt(token)role authenticatedsub 3b0e…91email ada@example.com3 The claims are publishedBEGIN;set_config('role', …, true)set_config('request.jwt.claims', …)-- LOCAL: gone at COMMIT4 Postgres decidescreate policy posts_read on public.posts for select to authenticated using (auth.uid() = author_id);anonno token, or the anon keyRLS enforcedauthenticateda signed-in user's access tokenRLS enforcedservice_rolethe service key, or a Studio sessionRLS BYPASSED — server onlyNo route handler filters rows for security. The same policies apply through REST, storage, realtime and the SDK,because every one of them reaches the database through the same asRole() transaction.
The same four stages run for a REST read, a storage download and a realtime delivery.
anon

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.

authenticated

A signed-in user’s access token. auth.uid() is their id inside every policy, so “their own rows” is one using clause.

service_role

BYPASSRLS. Legitimate in trusted server code that has no user to act as. Never in anything a browser or a mobile binary can read.

a complete policy set
-- 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());
The anon key is not a secret. It is a signed statement of which Postgres role a request runs as, and it is meant to ship in your frontend. The service key is the opposite: it bypasses every policy you wrote, and belongs only in server-side code.
RLS is off until you turn it on. A new table in 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
Operators and application users are separate populations. Studio accounts live in 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.

baselyra.sarimtools.comBaselyrabaselyrapostgres:17OverviewBUILDDatabaseSQL EditorAuthenticationStorageRealtimeAPI DocsAI AssistantCONFIGUREImportEmailSettingsdatabase upDatabase / public.postsSQLInsert rowSCHEMASpublicpostscommentsprofilesroomsmessagesSystemauth.usersauth.sessionsstorage.objectspublic.postsRLS on48,210 rowsidtitleauthor_idpublishedcreated_atid8f1c…a24eShipping two containers3b0e…91true2026-08-19 09:412d77…10bbRow level security, plainly3b0e…91true2026-08-18 17:02a903…7c1dWhy no ORMc142…08false2026-08-18 11:2655ae…3f70Realtime without a broker3b0e…91true2026-08-17 20:15e0b2…d489Importing from Supabasec142…08true2026-08-17 08:3371fd…6a02Signed URLs in practice9ab4…5dfalse2026-08-16 14:58c48e…b117The control database3b0e…91true2026-08-15 19:201a6b…ff93Eight dependenciesc142…08true2026-08-15 10:046ccd…2e58A policy set for chat9ab4…5dtrue2026-08-14 16:479b31…c605Presence without Redis3b0e…91true2026-08-14 09:1234c7…8e1aRefresh token rotationc142…08true2026-08-13 18:40bd15…4402One process, one log9ab4…5dfalse2026-08-13 12:0707a8…9f2cBackups are pg_dump3b0e…91true2026-08-12 21:33f26d…5b81What is not builtc142…08true2026-08-12 15:1948e0…7d36Storage keys and traversal9ab4…5dtrue2026-08-11 19:551–15 of 48,210
Database — grid, row editor, column and policy editing.
baselyra.sarimtools.comBaselyrabaselyrapostgres:17OverviewBUILDDatabaseSQL EditorAuthenticationStorageRealtimeAPI DocsAI AssistantCONFIGUREImportEmailSettingsdatabase upSQL EditorRun Cmd+EnterFormat1select p.id, p.title, count(c.*) as comments2from public.posts p3left join public.comments c on c.post_id = p.id4where p.published and p.created_at > now() - interval '30 days'5group by 1, 26order by comments desc limit7;_schema-aware autocompletionResult10 rows · 8.4 msidtitlecomments2d77…10bbRow level security, plainly8f1c…a24eShipping two containerse0b2…d489Importing from Supabase55ae…3f70Realtime without a broker1a6b…ff93Eight dependenciesc48e…b117The control database6ccd…2e58A policy set for chat71fd…6a02Signed URLs in practice9b31…c605Presence without Redis34c7…8e1aRefresh token rotation
SQL — editor, results grid, saved snippets, every run audited.
baselyra.sarimtools.comBaselyrabaselyrapostgres:17OverviewBUILDDatabaseSQL EditorAuthenticationStorageRealtimeAPI DocsAI AssistantCONFIGUREImportEmailSettingsdatabase upRealtimeClearTables with the NOTIFY triggerpublic.messagespublic.postspublic.presence_pingspublic.commentspublic.profilespublic.rooms3 sockets connectedLive eventspublic:messagesINSERTpublic.messages14:22:08.114{"id":"9c0…","room_id":42,"body":"deploying now"}public.messages14:21:55.902{"id":"7fb…","edited_at":"2026-08-22T14:21:…"}INSERTpublic.messages14:21:40.371{"id":"41d…","room_id":42,"body":"two containers"}DELETEpublic.messages14:21:02.660{"id":"a02…"} old row, primary key onlybroadcastroom:4214:20:58.019{"event":"typing","payload":{"user":"ada"}}presenceroom:4214:20:44.512{"ada":{"name":"Ada"},"lin":{"name":"Lin"}}INSERTpublic.messages14:20:11.238{"id":"c17…","room_id":9,"body":"policy passes"}public.posts14:19:47.004{"id":"8f1c…","published":true}INSERTpublic.messages14:19:12.775{"id":"3ea…","room_id":42,"body":"ship it"}every row re-read as the subscriber's own role before delivery
Realtime — which tables publish, and a live event inspector.
baselyra.sarimtools.comBaselyrabaselyrapostgres:17OverviewBUILDDatabaseSQL EditorAuthenticationStorageRealtimeAPI DocsAI AssistantCONFIGUREImportEmailSettingsdatabase upImportStart importTest connectionSupabasePostgresAppwriteFirebaseSQL dumpconnection stringpostgres://postgres:••••••••@db.abcdefgh.supabase.co:5432/postgresFound in the sourcepublic.posts48,210 rowspublic.comments191,004 rowspublic.profiles2,140 rowspublic.rooms312 rowsauth.users2,140 users · bcryptstorage: avatars1,904 objectsPasswordspreservedbcryptHashes are copied verbatim behinda bcrypt$ marker. Your users sign inwith the password they already have.Appwrite (argon2) and Firebase(keyed scrypt) cannot be carried;those users need a recovery email.rows · public.comments163,400 rows copied · 3 of 4
Import — inspect the source, tick what to copy, watch it stream.

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.

What each import source carries across
SourceTablesUsers PasswordsFilesPolicies
Supabasetypes, keys, indexesyespreserved (bcrypt)with URL + service keytranslated where possible
Postgrestypes, keys, indexesif auth.users existspreserved if bcryptn/atranslated where possible
Appwritecollections → tablesyeslost (argon2)yesnone to translate
Firebasecollections → tables, sampledyeslost (keyed scrypt)with storageBucketnone to translate
SQL dumpCREATE TABLE + COPYif it is in the dumppreserved if bcryptn/anone 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.