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:
| Bucket | Public | Limit | MIME allowlist |
|---|---|---|---|
avatars | yes | 5 MB | image/png, image/jpeg, image/gif, image/webp, image/avif |
uploads | no | none | none |
| Route | Body |
|---|---|
GET /storage/v1/bucket | Every 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/:id | The same fields, all optional |
DELETE /storage/v1/bucket/:id | 409 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
| Route | Does |
|---|---|
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
| Feature | Behaviour |
|---|---|
If-None-Match | A matching ETag answers 304 with no body. * always matches. |
Range: bytes=0-1023 | 206 with content-range. A multi-range request, or a unit that is not bytes, is answered in full. |
| An unsatisfiable range | 416 with content-range: bytes */184320 |
?download | Switches content-disposition from inline to attachment |
| Cache headers | A 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:
| Policy | Rule |
|---|---|
objects_select | the bucket is public, or you own the object, or the owner listed you in metadata.shared_with |
objects_insert | owner = auth.uid() |
objects_update | owner = auth.uid() |
objects_delete | owner = 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 see | Why | Fix |
|---|---|---|
404 on an object you can see in psql | RLS hid it. Missing and forbidden are deliberately the same answer. | Check the policies on storage.objects |
403 forbidden — Not allowed to write this object | The insert policy refused the metadata row | Usually a key that does not start with your user id under a restrictive folder policy |
413 from your proxy, not from Baselyra | The proxy's body limit is below STORAGE_MAX_FILE_BYTES | client_max_body_size 100m, or LimitRequestBody 0 on Apache |
A .json or .txt upload arrives re-serialised | It does not — the storage plugin drops the JSON and text parsers so those bodies are stored byte for byte | Nothing |
A public URL returns 404 Bucket … is not public | The bucket's public flag is false | PUT /storage/v1/bucket/:id with {"public":true}, or use a signed URL |
| A signed URL stops working early | expiresIn elapsed, or JWT_SECRET was rotated | Mint a new one |
409 deleting a bucket | It still holds objects | Empty it first — the refusal is the guard against a mistyped id |
| Files gone after a restore | The storage volume was not in the backup | Back up STORAGE_ROOT alongside the database — Backups |