Baselyra Docs

Troubleshooting

Every entry here is a real symptom with a real cause. Start with the four at the top: between them they account for most of the time anyone loses with Baselyra.

My query returns no rows

You call a table you can see in the Studio, and get this:

curl -s "$URL/rest/v1/notes?select=id,title" -H "apikey: $ANON_KEY"
[]

Almost always row level security, and it is working correctly. A policy denial is not an error — it is an empty array with a 200, because whether a row exists is itself information a policy can withhold.

  1. Check whether the table has policies at all

    select c.relname as table,
           c.relrowsecurity as rls_enabled,
           count(p.polname) as policies
      from pg_catalog.pg_class c
      join pg_catalog.pg_namespace n on n.oid = c.relnamespace
      left join pg_catalog.pg_policy p on p.polrelid = c.oid
     where n.nspname = 'public' and c.relkind in ('r','p')
     group by 1, 2
     order by rls_enabled, 1;

    RLS on and zero policies means the table denies everything to anon and authenticated. That is the safe default, not a bug — write a policy.

  2. Check which role you were

    An apikey alone is anon. A policy written for select to authenticated admits nothing to an anonymous caller. Send the user's access token as authorization: Bearer … as well.

  3. Reproduce it as that user, in SQL

    begin;
      select set_config('role', 'authenticated', true);
      select set_config('request.jwt.claims',
        '{"sub":"6c1f5c62-8e4c-4a6b-9a54-1c9d0a0d1a11","role":"authenticated"}', true);
      select * from public.notes;      -- exactly what that user sees over REST
    rollback;

    The Studio's grid runs with BYPASSRLS, so it shows the whole table whatever your policies say. This block is the only honest test.

If instead you seeIt is not RLS
403 insufficient_privilegeA missing table grant. grant select, insert, update, delete on public.t to anon, authenticated;
404 undefined_tableThe table is not in public, or the catalog snapshot is up to a minute stale
400 unknown columnA typo in select or a filter

Realtime connects but nothing ever arrives

The socket opens, subscribe() resolves SUBSCRIBED, and no event ever comes. There are exactly two causes.

1. The table has no trigger

Change feeds are opt-in per table.

select * from baselyra.realtime_tables;
select baselyra.enable_realtime('public.messages');

2. The proxy is eating the Upgrade header

One command settles it. A 101 is the whole test:

curl -i -N \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  "https://api.example.com/realtime/v1?apikey=$ANON_KEY"
ResponseMeans
HTTP/1.1 101 Switching ProtocolsThe path is clear
200 with HTMLThe proxy answered instead of upgrading
404 or 502The proxy is not routing that path to Baselyra at all

The working configurations are in deploy/. On Apache the rewrite must come before the catch-all ProxyPass, or the WebSocket is proxied as plain HTTP:

RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/?(.*) ws://127.0.0.1:3130/$1 [P,L]

ProxyPass        / http://127.0.0.1:3130/
ProxyPassReverse / http://127.0.0.1:3130/
ProxyTimeout 300
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl

On nginx, proxy_http_version 1.1 and both Upgrade headers, on every location:

map $http_upgrade $connection_upgrade { default upgrade; '' close; }

location / {
    proxy_pass http://127.0.0.1:3130;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 300s;
    proxy_buffering off;
}
Related symptomCause
Events stop after a few minutesA proxy idle timeout below the heartbeat interval. Raise proxy_read_timeout / ProxyTimeout to 300s.
Events for some rows onlyRLS hid the rest, or your filter excluded them. Not a transport problem.
Deletes arrive that a user should not seeDeletes are the one event not RLS-checked — the row is gone and cannot be re-read. Soft-delete instead.
The socket closes with 1008The token was rejected: expired, or JWT_SECRET changed.
Every message arrives twice in ReactA channel outlived a remount. Create it inside the effect and remove it in the cleanup.

No email ever arrives

Sign-up succeeds, the confirmation never comes, and nothing looks broken.

docker compose logs app | grep '\[mail\]'
[mail] SMTP not configured, would have sent to <ada@example.com>
[mail]   subject: Confirm your email address
[mail]   link:    http://127.0.0.1:3130/auth/v1/verify?token=8Yb…&type=confirmation

Open that link in development. For production, configure a relay and restart:

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false          # true only for implicit TLS on 465
SMTP_USER=…
SMTP_PASS=…
SMTP_FROM="Acme <no-reply@acme.com>"
docker compose up -d

Then Studio → EmailSend test, which uses your real templates and reports the SMTP error verbatim.

SymptomCause
SMTP is configured and still nothingA configured relay that rejects a message raises an error rather than falling back to the log. Look for [auth] … email failed in the log.
Mail sends but lands in spamSPF, DKIM and DMARC for the SMTP_FROM domain. Nothing Baselyra can do.
/recover answers 200 for an address that does not existDeliberate. Those endpoints answer identically either way so they cannot be used to enumerate accounts.
Nothing is sent on a re-signup for a confirmed addressA confirmation link would be a working credential minted on an unauthenticated request. The address owner gets a notice pointing at sign-in instead.

The Studio will not let me in

You created a user, the sign-up worked, and /login refuses those credentials.

Studio accountApplication user
Tablecontrol.platform_usersauth.users
Databasebaselyra_controlbaselyra
Created byscripts/migrate.js from BASELYRA_ADMIN_EMAIL/PASSWORD, or by an ownerPOST /auth/v1/signup, or the service key
Signs in atPOST /admin/v1/loginPOST /auth/v1/token

Use the credentials ./scripts/setup.sh printed on the first boot. If they are lost:

# put a new bootstrap password in .env and restart — migrate.js only sets the
# password when it CREATES the account, so first remove or rename the old row.
docker compose exec db psql -U baselyra -d baselyra_control \
  -c "select id, email, role_slug from control.platform_users;"

Then either delete that row and restart with BASELYRA_ADMIN_EMAIL and BASELYRA_ADMIN_PASSWORD set, or ask another owner to reset it from Studio → Settings → Team.

SymptomCause
select * from control.platform_users fails in the SQL editorCorrect and deliberate. It is in another database, and Postgres has no cross-database queries without FDW.
429 on the login pagePOST /admin/v1/login is capped at 5 per minute per IP. Wait.
Signed in, then every request is 401JWT_SECRET changed since the token was issued, or the hour is up.
403 on a page that used to workThe account is a viewer, or a custom role is missing a capability. GET /admin/v1/roles shows the matrix.
A second owner appears after a restartBASELYRA_ADMIN_EMAIL was left in .env after you changed your address in the Studio.
The Studio is a blank pageThe server is pointed at the Vite source rather than a built Studio.

An import says a table already exists

{"error":{"code":"import_rejected",
  "message":"public.posts already holds 4812 rows — deselect it, or choose replace mode",
  "details":null}}

This is the guard, not a fault. mode: "create" — the default — refuses to write into a target table that already holds rows, and names the table.

You wantDo
To keep what is thereDeselect that table from selection.tables and run the rest
To overwrite itSet "mode": "replace", which truncates the target first
To import into a clean namespaceSet "schema": "imported" and copy across afterwards
Other import failuresCause and fix
Could not read the source: …Credentials or the network. Run POST /admin/v1/import/test first — it is cheap and reports the source's version.
The stream stops with no done eventA proxy timeout below the 15-second heartbeat. Raise proxy_read_timeout / ProxyTimeout.
Rows copied, but the API answers 404 for the tableThe catalog cache. A run invalidates it on success; otherwise it refreshes within a minute.
Imported users cannot sign inAppwrite and Firebase hashes cannot be carried. Send recovery emails — the inspect warnings said so before the run.
A table imported with RLS on and no policiesThe source's policies did not translate. Deliberate: a table nobody can read is a bug you find in a minute. Read the warnings and write the policies.
A pooler connection string times out mid-copyA server-side cursor needs a session. Use the direct connection string, not the transaction-mode pooler.
403 on /admin/v1/import/*The Studio account is a viewer; import needs import.run.

The instance will not start

Log lineCause and fix
Missing required env var: DATABASE_URLOr JWT_SECRET. Both are required; the process refuses to start without them.
CONTROL_DATABASE_URL must name a different database than DATABASE_URLBoth point at one database. Unset CONTROL_DATABASE_URL and let it default.
[migrate] … database not ready, thirty timesPostgres never came up. docker compose logs db — usually a volume permission problem.
the SQL console role baselyra_sql does not existThe migrations were applied without scripts/migrate.js, which creates that role once before applying the directory. Run node scripts/migrate.js.
baselyra_sql is a superuser: the SQL console would be a shell on this hostSomeone granted it. alter role baselyra_sql nosuperuser and restart. The migration aborts on purpose.
A migration fails with a file and a character positionRead the statement at that position. Every file in db/ is idempotent; an edited file is re-run, so an unguarded create table fails on the second boot.

HTTP status quick reference

StatusMost likely cause
400A malformed filter, an unknown column or operator, a constraint violation, or a write with no filter
401The token expired (one hour), JWT_SECRET changed, or a refresh token was replayed and the whole chain was revoked
403A missing table grant, a Studio capability you do not hold, or a storage policy refusing a write. Never an RLS row denial.
404No such table in public, a stale catalog snapshot, a storage object your policies hide, or a public URL on a private bucket
406A singular response was asked for and the row count was not 1
408Over DATABASE_STATEMENT_TIMEOUT_MS (15s). Index the columns your filters and policies use.
409A unique or foreign key violation, or deleting a bucket that still holds objects
413Over STORAGE_MAX_FILE_BYTES or the bucket limit — or your proxy's body limit, in which case the error page is the proxy's
429300 requests per minute per IP globally, or a per-account sign-in lockout with Retry-After
503An /ai/v1 route with no DEEPSEEK_API_KEY

Things that look broken and are not

BehaviourWhy
bigint and numeric arrive as stringsDeliberate — a JavaScript number cannot hold either exactly, and money must not round
Signing up with an existing address returns a 200 and a user you cannot sign in asA decoy, so the endpoint cannot be used to test whether an address is registered. The real owner gets an email.
/recover and /magiclink always succeedSame reason, plus a 350 ms response floor so timing does not reveal it either
A public image does not change after a re-uploadPublic objects are served immutable with a year's max-age. Add a cache-busting query parameter.
An unfiltered DELETE is refusedOne forgotten query parameter otherwise empties the table, and RLS does not save you. Prefer: unsafe-mutation is the opt-out.
A viewer cannot read the import historyA connection string is a credential, so the whole import area needs import.run
The realtime feed goes quiet for up to 30 seconds after a database restartThe listener reconnects with exponential backoff and jitter

Collecting evidence

curl -s https://api.example.com/health                  # is it up, are both databases up
docker compose ps                                       # healthy?
docker compose logs --tail=200 app
docker compose logs --tail=200 db
docker compose exec db psql -U baselyra -d baselyra -c '\dt public.*'
docker compose exec db psql -U baselyra -d baselyra_control -c 'select count(*) from control.platform_users;'

Then the end-to-end check, which drives auth, REST, storage, realtime, the admin API and the Studio the way a real client would:

./scripts/smoke.sh http://127.0.0.1:3130 admin@example.com 'password'

Edit this page Report a problem

Esc
navigate open Esc close