Quickstart
Everything on this page is copy-pasteable and every response is the real one. At the end you will have an instance running, a table with policies on it, an application user, and a query that returns different rows depending on who asks.
Clone and generate secrets
git clone https://github.com/baselyra/baselyra.git cd baselyra ./scripts/setup.shsetup.shrefuses to run when.envalready exists, so it can never overwrite your secrets. It copies.env.exampleto.env, replacesJWT_SECRET,POSTGRES_PASSWORDandBASELYRA_ADMIN_PASSWORDwith fresh random values,chmod 600s the file, and prints the admin credentials once.Wrote .env Admin email : admin@example.com Admin password : k3Qm9xTdL2wvB8pR Save that password now — it is only stored hashed after the first boot.Set the two URLs
Open
.envand set at least these. On a laptop,http://127.0.0.1:3130for both is fine.BASELYRA_PUBLIC_URL=https://api.example.com # where this instance is reachable BASELYRA_SITE_URL=https://app.example.com # where your frontend livesBASELYRA_PUBLIC_URLgoes into signed storage URLs and email links.BASELYRA_SITE_URLis whereGET /auth/v1/verifysends a user after they click a confirmation link — and it is always that value, never anything from the request, which is what keeps the endpoint from being an open redirect.Start it
docker compose up -d --build docker compose logs -f appThe app container waits for Postgres, creates both databases if they are absent, applies
db/project/*.sqlanddb/control/*.sqlin filename order to their own database, creates the first Studio account, and starts listening. You are looking for[migrate] doneandBaselyra listening on 0.0.0.0:3000.curl -s http://127.0.0.1:3130/health{"status":"ok","service":"baselyra","version":"0.1.0","database":"up","time":"2026-08-24T09:14:02.481Z"}Then run the end-to-end check, which exercises auth, REST, storage, realtime, the admin API and the Studio the way a real client would, and deletes everything it created on the way out.
./scripts/smoke.sh http://127.0.0.1:3130 admin@example.com 'k3Qm9xTdL2wvB8pR'Sign in to the Studio and take your keys
Open http://127.0.0.1:3130 and sign in with the printed credentials. The Overview page carries a Connect panel with your project URL and anon key. From the command line:
ADMIN_TOKEN=$(curl -s http://127.0.0.1:3130/admin/v1/login \ -H 'content-type: application/json' \ -d '{"email":"admin@example.com","password":"k3Qm9xTdL2wvB8pR"}' \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') curl -s http://127.0.0.1:3130/admin/v1/keys -H "authorization: Bearer $ADMIN_TOKEN"{"anonKey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6ImJhc2VseXJhIiwiaWF0IjoxNzcxOTk1MjQyLCJleHAiOjIwODc1NzEyNDJ9.tS0…", "serviceKey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoiYmFzZWx5cmEiLCJpYXQiOjE3NzE5OTUyNDIsImV4cCI6MjA4NzU3MTI0Mn0.Qa4…"}Both are signed with
JWT_SECRETand last ten years. The anon key ships in your frontend; the service key never leaves a server. RotatingJWT_SECRETinvalidates both and every session at once.Create a table — and turn RLS on in the same breath
Studio → SQL (or Cmd + K, type "sql"). The editor defaults to read-only; flip the segmented control beside Run to Write, which tints itself with the warning colour because that is the mode that can drop things. Then Cmd + Enter.
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 check (length(title) between 1 and 200), body text not null default '', published boolean not null default false, created_at timestamptz not null default now() ); create index posts_author_idx on public.posts (author); alter table public.posts enable row level security; create policy posts_read_published on public.posts for select to anon, authenticated using (published or author = (select auth.uid())); create policy posts_insert_own on public.posts for insert to authenticated with check (author = (select auth.uid())); create policy posts_update_own on public.posts for update to authenticated using (author = (select auth.uid())) with check (author = (select auth.uid())); create policy posts_delete_own on public.posts for delete to authenticated using (author = (select auth.uid()));Create an application user
curl -s http://127.0.0.1:3130/auth/v1/signup \ -H 'content-type: application/json' \ -d '{"email":"ada@example.com","password":"correct-horse-battery","data":{"name":"Ada"}}'{"user":{"id":"6c1f5c62-8e4c-4a6b-9a54-1c9d0a0d1a11","aud":"authenticated","role":"authenticated", "email":"ada@example.com","phone":null,"email_confirmed_at":null,"phone_confirmed_at":null, "confirmed_at":null,"last_sign_in_at":null,"banned_until":null, "app_metadata":{"provider":"email","providers":["email"]}, "user_metadata":{"name":"Ada"},"created_at":"2026-08-24T09:20:11.004Z", "updated_at":"2026-08-24T09:20:11.004Z"}, "session":null}sessionisnullbecauseAUTH_CONFIRM_EMAILdefaults totrue. With no SMTP configured the confirmation email is printed to the log instead of sent:docker compose logs app | grep '\[mail\]'[mail] SMTP not configured, would have sent to <ada@example.com> [mail] subject: Confirm your email [mail] link: http://127.0.0.1:3130/auth/v1/verify?token=8Yb…&type=confirmationOpen that link, or set
AUTH_CONFIRM_EMAIL=falsewhile developing. Configure SMTP before you go live — see Email.Sign in and use the API as that user
curl -s 'http://127.0.0.1:3130/auth/v1/token?grant_type=password' \ -H 'content-type: application/json' \ -d '{"email":"ada@example.com","password":"correct-horse-battery"}'{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", "token_type":"bearer", "expires_in":3600, "expires_at":1771998842, "refresh_token":"o3Hn7Vb2XkQ0…", "user":{"id":"6c1f5c62-8e4c-4a6b-9a54-1c9d0a0d1a11","email":"ada@example.com", "…":"…"}}Write a post as Ada. Note that
authoris never sent.curl -s http://127.0.0.1:3130/rest/v1/posts \ -H "apikey: $ANON_KEY" \ -H "authorization: Bearer $ADA_TOKEN" \ -H 'content-type: application/json' \ -H 'Prefer: return=representation' \ -d '{"title":"Hello","body":"First post","published":true}'[{"id":1,"author":"6c1f5c62-8e4c-4a6b-9a54-1c9d0a0d1a11","title":"Hello", "body":"First post","published":true,"created_at":"2026-08-24T09:24:55.117Z"}]Now read it anonymously. The same URL, no bearer token, and the policy decides.
curl -s "http://127.0.0.1:3130/rest/v1/posts?select=id,title&order=created_at.desc" \ -H "apikey: $ANON_KEY"[{"id":1,"title":"Hello"}]Add an unpublished draft as Ada and the anonymous request still returns only the published row — with no filter, no
whereclause, and no code of yours involved. That is row level security working.Talk to it from an application
npm install @baselyra/clientimport { createClient } from '@baselyra/client'; const bl = createClient( import.meta.env.VITE_BASELYRA_URL, import.meta.env.VITE_BASELYRA_ANON_KEY, ); await bl.auth.signInWithPassword({ email, password }); const { data, error } = await bl .from('posts') .select('id, title, created_at') .eq('published', true) .order('created_at', { ascending: false }) .limit(20); if (error) console.error(error.code, error.message);Nothing throws: every call resolves to
{ data, error }. Framework setups are in Next.js, React, Flutter, PHP and Python.Turn on realtime
Change feeds are opt-in per table, because a
NOTIFYtrigger on a hot table you are not watching is pure cost.select baselyra.enable_realtime('public.posts');const channel = bl.channel('public:posts'); channel.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts', filter: 'published=eq.true' }, ({ new: row }) => console.log('new post', row)); await channel.subscribe();Each changed row is re-read as your role before it is sent, so a subscription can never show more than a
selectwould. Details and the reverse-proxy rules realtime needs are in Realtime.Store a file
await bl.storage.from('avatars').upload(user.id + '/me.png', file); const { data } = bl.storage.from('avatars').getPublicUrl(user.id + '/me.png');Two buckets are seeded:
avatars(public, images only, 5 MB limit) anduploads(private, no limits). Creating buckets needs the service key or the Studio. See Storage.
What to do before you ship
- Every table in
publichas RLS on and at least one policy — the audit query. - SMTP is configured, so recovery emails leave the machine instead of landing in your log file.
CORS_ORIGINSnames your origins rather than*.BASELYRA_ADMIN_EMAILandBASELYRA_ADMIN_PASSWORDare cleared from.envafter your first sign-in.- The service key appears in no client bundle. Grep your frontend for it.
The full list is in the hardening checklist.
If something went wrong
| Symptom | Cause |
|---|---|
database not ready repeating in the logs | Postgres is still initialising. scripts/migrate.js retries thirty times at two seconds. Past that, check docker compose logs db for a volume permission problem. |
Sign-in returns email_not_confirmed | Working as configured. Open the link from the log, or set AUTH_CONFIRM_EMAIL=false for local development. |
A table with rows returns [] | RLS is on and no policy admits your role. This is the correct default — add a policy. |
| Realtime connects but never delivers | The table has no trigger, or a proxy is eating the Upgrade header. See Troubleshooting. |
More in Troubleshooting.