Baselyra Docs

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.

  1. Clone and generate secrets

    git clone https://github.com/baselyra/baselyra.git
    cd baselyra
    ./scripts/setup.sh

    setup.sh refuses to run when .env already exists, so it can never overwrite your secrets. It copies .env.example to .env, replaces JWT_SECRET, POSTGRES_PASSWORD and BASELYRA_ADMIN_PASSWORD with 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.
  2. Set the two URLs

    Open .env and set at least these. On a laptop, http://127.0.0.1:3130 for 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 lives

    BASELYRA_PUBLIC_URL goes into signed storage URLs and email links. BASELYRA_SITE_URL is where GET /auth/v1/verify sends 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.

  3. Start it

    docker compose up -d --build
    docker compose logs -f app

    The app container waits for Postgres, creates both databases if they are absent, applies db/project/*.sql and db/control/*.sql in filename order to their own database, creates the first Studio account, and starts listening. You are looking for [migrate] done and Baselyra 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'
  4. 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_SECRET and last ten years. The anon key ships in your frontend; the service key never leaves a server. Rotating JWT_SECRET invalidates both and every session at once.

  5. 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()));
  6. 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}

    session is null because AUTH_CONFIRM_EMAIL defaults to true. 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=confirmation

    Open that link, or set AUTH_CONFIRM_EMAIL=false while developing. Configure SMTP before you go live — see Email.

  7. 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 author is 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 where clause, and no code of yours involved. That is row level security working.

  8. Talk to it from an application

    npm install @baselyra/client
    import { 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.

  9. Turn on realtime

    Change feeds are opt-in per table, because a NOTIFY trigger 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 select would. Details and the reverse-proxy rules realtime needs are in Realtime.

  10. 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) and uploads (private, no limits). Creating buckets needs the service key or the Studio. See Storage.

What to do before you ship

  • Every table in public has 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_ORIGINS names your origins rather than *.
  • BASELYRA_ADMIN_EMAIL and BASELYRA_ADMIN_PASSWORD are cleared from .env after 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

SymptomCause
database not ready repeating in the logsPostgres 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_confirmedWorking 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 deliversThe table has no trigger, or a proxy is eating the Upgrade header. See Troubleshooting.

More in Troubleshooting.

Edit this page Report a problem

Esc
navigate open Esc close