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.
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
anonandauthenticated. That is the safe default, not a bug — write a policy.Check which role you were
An
apikeyalone isanon. A policy writtenfor select to authenticatedadmits nothing to an anonymous caller. Send the user's access token asauthorization: Bearer …as well.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 see | It is not RLS |
|---|---|
403 insufficient_privilege | A missing table grant. grant select, insert, update, delete on public.t to anon, authenticated; |
404 undefined_table | The table is not in public, or the catalog snapshot is up to a minute stale |
400 unknown column | A 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"
| Response | Means |
|---|---|
HTTP/1.1 101 Switching Protocols | The path is clear |
200 with HTML | The proxy answered instead of upgrading |
404 or 502 | The 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 symptom | Cause |
|---|---|
| Events stop after a few minutes | A proxy idle timeout below the heartbeat interval. Raise proxy_read_timeout / ProxyTimeout to 300s. |
| Events for some rows only | RLS hid the rest, or your filter excluded them. Not a transport problem. |
| Deletes arrive that a user should not see | Deletes are the one event not RLS-checked — the row is gone and cannot be re-read. Soft-delete instead. |
The socket closes with 1008 | The token was rejected: expired, or JWT_SECRET changed. |
| Every message arrives twice in React | A 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 → Email → Send test, which uses your real templates and reports the SMTP error verbatim.
| Symptom | Cause |
|---|---|
| SMTP is configured and still nothing | A 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 spam | SPF, DKIM and DMARC for the SMTP_FROM domain. Nothing Baselyra can do. |
/recover answers 200 for an address that does not exist | Deliberate. Those endpoints answer identically either way so they cannot be used to enumerate accounts. |
| Nothing is sent on a re-signup for a confirmed address | A 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 account | Application user | |
|---|---|---|
| Table | control.platform_users | auth.users |
| Database | baselyra_control | baselyra |
| Created by | scripts/migrate.js from BASELYRA_ADMIN_EMAIL/PASSWORD, or by an owner | POST /auth/v1/signup, or the service key |
| Signs in at | POST /admin/v1/login | POST /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.
| Symptom | Cause |
|---|---|
select * from control.platform_users fails in the SQL editor | Correct and deliberate. It is in another database, and Postgres has no cross-database queries without FDW. |
429 on the login page | POST /admin/v1/login is capped at 5 per minute per IP. Wait. |
Signed in, then every request is 401 | JWT_SECRET changed since the token was issued, or the hour is up. |
403 on a page that used to work | The 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 restart | BASELYRA_ADMIN_EMAIL was left in .env after you changed your address in the Studio. |
| The Studio is a blank page | The 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 want | Do |
|---|---|
| To keep what is there | Deselect that table from selection.tables and run the rest |
| To overwrite it | Set "mode": "replace", which truncates the target first |
| To import into a clean namespace | Set "schema": "imported" and copy across afterwards |
| Other import failures | Cause 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 event | A proxy timeout below the 15-second heartbeat. Raise proxy_read_timeout / ProxyTimeout. |
Rows copied, but the API answers 404 for the table | The catalog cache. A run invalidates it on success; otherwise it refreshes within a minute. |
| Imported users cannot sign in | Appwrite 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 policies | The 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-copy | A 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 line | Cause and fix |
|---|---|
Missing required env var: DATABASE_URL | Or JWT_SECRET. Both are required; the process refuses to start without them. |
CONTROL_DATABASE_URL must name a different database than DATABASE_URL | Both point at one database. Unset CONTROL_DATABASE_URL and let it default. |
[migrate] … database not ready, thirty times | Postgres never came up. docker compose logs db — usually a volume permission problem. |
the SQL console role baselyra_sql does not exist | The 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 host | Someone granted it. alter role baselyra_sql nosuperuser and restart. The migration aborts on purpose. |
| A migration fails with a file and a character position | Read 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
| Status | Most likely cause |
|---|---|
400 | A malformed filter, an unknown column or operator, a constraint violation, or a write with no filter |
401 | The token expired (one hour), JWT_SECRET changed, or a refresh token was replayed and the whole chain was revoked |
403 | A missing table grant, a Studio capability you do not hold, or a storage policy refusing a write. Never an RLS row denial. |
404 | No such table in public, a stale catalog snapshot, a storage object your policies hide, or a public URL on a private bucket |
406 | A singular response was asked for and the row count was not 1 |
408 | Over DATABASE_STATEMENT_TIMEOUT_MS (15s). Index the columns your filters and policies use. |
409 | A unique or foreign key violation, or deleting a bucket that still holds objects |
413 | Over STORAGE_MAX_FILE_BYTES or the bucket limit — or your proxy's body limit, in which case the error page is the proxy's |
429 | 300 requests per minute per IP globally, or a per-account sign-in lockout with Retry-After |
503 | An /ai/v1 route with no DEEPSEEK_API_KEY |
Things that look broken and are not
| Behaviour | Why |
|---|---|
bigint and numeric arrive as strings | Deliberate — 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 as | A decoy, so the endpoint cannot be used to test whether an address is registered. The real owner gets an email. |
/recover and /magiclink always succeed | Same reason, plus a 350 ms response floor so timing does not reveal it either |
| A public image does not change after a re-upload | Public objects are served immutable with a year's max-age. Add a cache-busting query parameter. |
An unfiltered DELETE is refused | One 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 history | A 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 restart | The 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'