Baselyra Docs

Storage

Files live on the local disk under STORAGE_ROOT, one directory per bucket. Their metadata lives in storage.objects, and that table has row level security — so who may read, write and delete a file is an ordinary Postgres policy, written exactly like the ones governing a table.

Buckets

Two are seeded on first boot:

BucketPublicLimitMIME allowlist
avatarsyes5 MBimage/png, image/jpeg, image/gif, image/webp, image/avif
uploadsnononenone
RouteBody
GET /storage/v1/bucketEvery bucket the caller's policies allow
POST /storage/v1/bucket{id, name?, public?, file_size_limit?, allowed_mime_types?}201
GET /storage/v1/bucket/:id
PUT /storage/v1/bucket/:idThe same fields, all optional
DELETE /storage/v1/bucket/:id409 while the bucket still holds objects
curl -s -X POST "$URL/storage/v1/bucket" \
  -H "authorization: Bearer $SERVICE_KEY" -H 'content-type: application/json' \
  -d '{"id":"invoices","public":false,"file_size_limit":10485760,
       "allowed_mime_types":["application/pdf"]}'
{"id":"invoices","name":"invoices","public":false,"file_size_limit":10485760,
 "allowed_mime_types":["application/pdf"],"owner":null,
 "created_at":"2026-08-24T11:40:02.117Z","updated_at":"2026-08-24T11:40:02.117Z"}

Creating, reconfiguring and deleting buckets requires the service key or the Studio. db/project/002_rls.sql gives storage.buckets no INSERT, UPDATE or DELETE policy at all, and that absence is the denial — a browser holding the anon key cannot create a bucket however it asks.

A bucket id is 1–63 characters, starts with a letter or digit, and contains only letters, digits, dot, dash or underscore. Seven names are reserved because the object routes claim those first path segments: public, list, move, copy, sign, info, upload.

Deleting a non-empty bucket is refused. The ON DELETE CASCADE would have taken every file with it and said nothing:

{"error":{"code":"conflict","message":"Bucket invoices still contains objects","details":null}}

Objects

RouteDoes
POST /storage/v1/object/:bucket/*Upload. Refuses to overwrite unless x-upsert: true.
PUT /storage/v1/object/:bucket/*Upload, always overwriting
GET /storage/v1/object/:bucket/*Download, RLS checked
DELETE /storage/v1/object/:bucket/*Delete
GET /storage/v1/object/public/:bucket/*Public buckets only, no credentials
POST /storage/v1/object/list/:bucket{prefix?, limit?, offset?, sortBy?}
POST /storage/v1/object/move{bucketId, sourceKey, destinationKey}
POST /storage/v1/object/copy{bucketId, sourceKey, destinationKey}
POST /storage/v1/object/sign/:bucket/*{expiresIn} → a signed URL
GET /storage/v1/object/sign/:bucket/*?token=…Download by signature, no credentials

Uploading

curl -s -X POST "$URL/storage/v1/object/avatars/$USER_ID/me.png" \
  -H "authorization: Bearer $TOKEN" \
  -H 'content-type: image/png' \
  --data-binary @me.png
curl -s -X POST "$URL/storage/v1/object/avatars/$USER_ID/me.png" \
  -H "authorization: Bearer $TOKEN" \
  -F "file=@me.png;type=image/png"
await bl.storage.from('avatars').upload(user.id + '/me.png', file, { upsert: true });
{"Id":"a1e3c0f2-6d5b-4d02-9d33-7b2a8f1c0e44",
 "Key":"avatars/6c1f5c62-…/me.png",
 "size":40213,
 "checksum":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
 "mimeType":"image/png"}

The checksum is SHA-256 of the bytes and becomes the object's ETag.

Uploads are capped twice: by STORAGE_MAX_FILE_BYTES globally (50 MB) and by the bucket's own file_size_limit.

{"error":{"code":"payload_too_large","message":"Object exceeds the 5242880 byte limit for this bucket","details":null}}
{"error":{"code":"bad_request","message":"Bucket avatars does not accept application/pdf","details":null}}
{"error":{"code":"forbidden","message":"Not allowed to write this object","details":null}}

Object keys

Up to 1024 characters. Forward slashes are folder separators — there are no real directories in the metadata, only a prefix convention. Rejected outright: .. segments, absolute paths, backslashes, control characters, and anything that resolves outside its bucket directory. The check runs on every percent-decoded form of the key and is then re-asserted against the resolved absolute path as a last line.

Downloading

curl -s "$URL/storage/v1/object/uploads/reports/q1.pdf" \
  -H "authorization: Bearer $TOKEN" -o q1.pdf -D -
HTTP/1.1 200 OK
accept-ranges: bytes
content-type: application/pdf
content-length: 184320
etag: "9f86d081884c…"
last-modified: Sun, 24 Aug 2026 11:44:10 GMT
content-disposition: inline; filename*=UTF-8''q1.pdf
cache-control: private, no-store
FeatureBehaviour
If-None-MatchA matching ETag answers 304 with no body. * always matches.
Range: bytes=0-1023206 with content-range. A multi-range request, or a unit that is not bytes, is answered in full.
An unsatisfiable range416 with content-range: bytes */184320
?downloadSwitches content-disposition from inline to attachment
Cache headersA public object is public, max-age=31536000, immutable; a private one is private, no-store, so it can never rest in a shared cache

Public URLs

For a bucket with public = true:

https://api.example.com/storage/v1/object/public/avatars/6c1f5c62-…/me.png

No auth header, and the client constructs it without a request. The route checks the bucket's public flag explicitly as well as reading through anon, so a policy edit can never quietly open it. On a private bucket it is a 404:

{"error":{"code":"not_found","message":"Bucket invoices is not public","details":null}}

Signed URLs

curl -s -X POST "$URL/storage/v1/object/sign/invoices/2026/march.pdf" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"expiresIn":900}'
{"signedURL":"/storage/v1/object/sign/invoices/2026/march.pdf?token=1774000000.9c2f…",
 "url":"https://api.example.com/storage/v1/object/sign/invoices/2026/march.pdf?token=1774000000.9c2f…",
 "expiresAt":1774000000}

expiresIn is between 1 second and 7 days. The token is an HMAC-SHA256 over bucket, key and expiry using JWT_SECRET.

Minting is itself an authorised read: only someone whose policies let them see the object can mint a URL for it. Redemption is not — the signature is the authorisation, which is the entire point of handing one out. Treat a signed URL as a bearer credential and keep the expiry short.

Listing

curl -s -X POST "$URL/storage/v1/object/list/uploads" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"prefix":"teams/acme/","limit":100,"sortBy":{"column":"name","order":"asc"}}'
[{"name":"contracts","id":null,"size":null,"mime_type":null,"checksum":null,
  "metadata":null,"created_at":null,"updated_at":null},
 {"name":"logo.png","id":"3c9a…","size":8210,"mime_type":"image/png",
  "checksum":"b1946ac9…","metadata":{},"created_at":"2026-08-20T09:00:00.000Z",
  "updated_at":"2026-08-20T09:00:00.000Z"}]

The result is folder-grouped, like a file browser: an entry with id: null and size: null is a folder — a distinct next path segment — and anything else is an object. Sortable columns: name, size, created_at, updated_at, mime_type. limit is 1–1000. The listing runs through RLS, so it shows only what the caller may see.

Move and copy

curl -s -X POST "$URL/storage/v1/object/move" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"bucketId":"uploads","sourceKey":"draft.pdf","destinationKey":"archive/2026/draft.pdf"}'
{"message":"Successfully moved","Key":"uploads/archive/2026/draft.pdf"}

Both are transactional: the row change and the filesystem operation happen inside one transaction, so if the rename fails the row change rolls back and the object stays exactly where it was. Both are within one bucket — there is no cross-bucket move.

Policies

db/project/002_rls.sql ships four permissive defaults on storage.objects:

PolicyRule
objects_selectthe bucket is public, or you own the object, or the owner listed you in metadata.shared_with
objects_insertowner = auth.uid()
objects_updateowner = auth.uid()
objects_deleteowner = auth.uid()

owner is set from the caller's user id at upload. A Studio operator uploading through the console leaves owner null — a platform account is not a row in auth.users, and writing its id into a column that references auth.users would violate the foreign key.

A permissive policy only ever widens access, so narrowing needs as restrictive:

create policy user_files_own_folder on storage.objects
  as restrictive
  for all to anon, authenticated
  using (bucket_id <> 'user-files'
         or split_part(name, '/', 1) = (select auth.uid())::text)
  with check (bucket_id <> 'user-files'
              or split_part(name, '/', 1) = (select auth.uid())::text);

The full worked example, including why the defaults alone do not stop a user uploading into someone else's folder, is policy set 5.

Sharing one object with one person, without a new policy:

update storage.objects
   set metadata = jsonb_set(metadata, '{shared_with}', '["6c1f5c62-…"]'::jsonb)
 where bucket_id = 'uploads' and name = 'reports/q1.pdf';

In the client

const bucket = bl.storage.from('avatars');

await bucket.upload(user.id + '/me.png', file);
await bucket.update(user.id + '/me.png', file);                     // overwrite
await bucket.list(user.id + '/', { limit: 100, sortBy: { column: 'name', order: 'asc' } });
await bucket.move('me.png', 'archive/me.png');
await bucket.copy('me.png', 'archive/me.png');
await bucket.remove([user.id + '/me.png']);

const { data: blob } = await bucket.download(user.id + '/me.png');  // data is a Blob
const { data } = await bucket.createSignedUrl(user.id + '/me.png', 3600);
bucket.getPublicUrl(user.id + '/me.png');                           // no request, public buckets only

await bl.storage.listBuckets();
await bl.storage.createBucket('invoices', { public: false });       // service key

Uploads accept a browser File or Blob, a Node Buffer, an ArrayBuffer, a string or FormData. remove() stops at the first failure and reports what was already deleted, so the caller knows the operation was partial.

Operational notes

  • Delete removes the row first. Once the row is gone the object is invisible to every API, so an unlink that fails leaves a harmless orphan rather than a live row pointing at nothing. A file already missing is still a success.
  • There are no quotas. Per-bucket size limits cap a single file, not the total. Nothing stops a bucket filling the disk — watch the volume.
  • Deleting a bucket directory happens after the rows are gone, and also sweeps temporary files left by uploads that died mid-stream.
  • Keep your reverse proxy's body limit above STORAGE_MAX_FILE_BYTES, or large uploads fail at the proxy with the proxy's own error page.

Failure modes

What you seeWhyFix
404 on an object you can see in psqlRLS hid it. Missing and forbidden are deliberately the same answer.Check the policies on storage.objects
403 forbiddenNot allowed to write this objectThe insert policy refused the metadata rowUsually a key that does not start with your user id under a restrictive folder policy
413 from your proxy, not from BaselyraThe proxy's body limit is below STORAGE_MAX_FILE_BYTESclient_max_body_size 100m, or LimitRequestBody 0 on Apache
A .json or .txt upload arrives re-serialisedIt does not — the storage plugin drops the JSON and text parsers so those bodies are stored byte for byteNothing
A public URL returns 404 Bucket … is not publicThe bucket's public flag is falsePUT /storage/v1/bucket/:id with {"public":true}, or use a signed URL
A signed URL stops working earlyexpiresIn elapsed, or JWT_SECRET was rotatedMint a new one
409 deleting a bucketIt still holds objectsEmpty it first — the refusal is the guard against a mistyped id
Files gone after a restoreThe storage volume was not in the backupBack up STORAGE_ROOT alongside the database — Backups

Edit this page Report a problem

Esc
navigate open Esc close