Getting started
Baselyra is a self-hosted backend that runs as two containers: one Node process and one Postgres. This page is the orientation — what the pieces are, which one decides who may read a row, and where to go next. If you would rather type than read, go straight to the quickstart.
What you get
One Node 22 process serves every API prefix and the admin Studio. It talks to one Postgres 17 server, which holds two databases: the project database with your tables, and the control database with Baselyra's own operating data.
| Prefix | What it is |
|---|---|
/rest/v1 | An auto-generated REST API over every table, view and function in public. Read from the catalog on demand, so a table created a second ago is queryable now. |
/auth/v1 | Sign-up, sign-in, refresh-token rotation, magic links, one-time codes, phone codes over SMS and third-party sign-in. |
/storage/v1 | Buckets on local disk, with metadata in storage.objects so the same policies guard files as guard tables. |
/realtime/v1 | One WebSocket carrying database change feeds, broadcast and presence. |
/admin/v1 | What the Studio talks to: schema, SQL, users, settings, keys, imports, webhooks and scheduled jobs. |
/ai/v1 | Optional DeepSeek routes. Every one answers 503 until DEEPSEEK_API_KEY is set. |
/health | Liveness plus whether both databases answer SELECT 1. |
/ | The compiled Studio, served last so it never shadows an API prefix. |
Postgres decides, not JavaScript
This is the single idea the rest of the product is built on. Baselyra performs no authorisation in JavaScript. No route handler checks ownership and no query is narrowed for security reasons in the server process. Every request that touches your data runs inside a transaction that has switched to the caller's Postgres role and published their verified JWT claims to the session; row level security policies do the rest.
| What the caller sent | Postgres role | RLS |
|---|---|---|
| Nothing, or the anon key | anon | Enforced |
Authorization: Bearer <user access token> | authenticated | Enforced, and auth.uid() is that user |
| The service key | service_role | Bypassed — the role has BYPASSRLS |
A Studio session on /admin/v1 | service_role | Bypassed |
The anon key is public and is meant to be. It is not a password; it only names the role a request runs as. The service key is the opposite: it reads and writes every row in the project database, so it belongs on a server and nowhere else.
Two identities that are not the same
Baselyra keeps operators and application users in two unrelated tables in two unrelated databases. Conflating them is the mistake this project most wants you not to make.
| Studio account | Application user | |
|---|---|---|
| Who | You, and whoever else operates the instance | The end users of the app you build |
| Table | control.platform_users | auth.users |
| Database | baselyra_control | baselyra (the project database) |
| Signs in at | POST /admin/v1/login | POST /auth/v1/token |
| Token carries | role: service_role, typ: "platform" | role: authenticated, sub, app_metadata |
| Can open the Studio | Yes | Never |
Visible to /rest/v1 | No — it is in another database | Only through your own policies |
Consequences worth knowing on day one:
- No row in
auth.userscan ever open the Studio, however it is configured. Studio tokens carry atyp: "platform"claim inside the signature, and exactly one endpoint mints it. select * from control.platform_usersin the SQL editor fails with relation does not exist. That is the correct outcome: the hashes are in a database that connection cannot address.- There is no
is_admincolumn and noauth.is_admin()function. Both were removed. An application's own admin role lives inapp_metadataand is read in a policy as(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'.
The shortest real example
A table, a policy, a user, a query. Every line of this works against a fresh install.
create table public.posts (
id bigint generated always as identity primary key,
author uuid not null default auth.uid() references auth.users(id) on delete cascade,
title text not null,
published boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.posts enable row level security;
create policy posts_read on public.posts
for select to anon, authenticated
using (published or author = (select auth.uid()));
create policy posts_write_own on public.posts
for insert to authenticated
with check (author = (select auth.uid()));
curl -s "$URL/rest/v1/posts?select=id,title&published=eq.true" -H "apikey: $ANON_KEY"
[{"id":1,"title":"Hello"}]
Nobody sent an author. The column defaults to auth.uid(), and the policy's
with check would refuse anything else — ownership is established by the database,
not by trusting the client.
What Baselyra does not do
Stated plainly, because finding out on day two is worse.
| Not built | What you have instead |
|---|---|
| Edge functions In progress | Postgres functions called over /rest/v1/rpc/:fn |
| Replication, read replicas, failover In progress | One Postgres. Backups are pg_dump plus the storage volume. |
| An S3 or object-storage backend In progress | Files on the local disk of the host running the app |
| Image transformation In progress | Files come back exactly as they were uploaded |
| A project switcher In progress | control.projects holds one row that every lookup resolves through, but nothing creates a second |
| Apple sign-in | Google, GitHub, LinkedIn, Facebook, Instagram, TikTok and Envato — see Third-party sign-in |
| Two-factor authentication on Studio accounts | A password, rate limiting, and an audit log |
Database webhooks and cron-scheduled jobs are built — they run in-process with
their queue in the project database, under /admin/v1/hooks/* — but the Studio has
no page for either yet.