Storage
File upload and download with local or S3 backends. Bucket policies enforced by RLS.
Buckets are declared in instancez.yaml. Objects are stored locally or in S3. Authorization is enforced by RLS policies using the same auth.uid() helpers available on your own tables.
Declaring buckets
Section titled “Declaring buckets”storage: avatars: public: true max_size: 5MB types: - image/* rls: - operations: [insert] with_check: "auth.uid() IS NOT NULL" - operations: [update] using: "auth.uid() IS NOT NULL" with_check: "auth.uid() IS NOT NULL" - operations: [delete] using: "auth.uid() IS NOT NULL"
documents: public: false max_size: 10MB rls: - operations: [select, insert, update, delete] using: "auth.uid() IS NOT NULL" with_check: "auth.uid() IS NOT NULL"| Key | Type | Description |
|---|---|---|
public | bool | When true, objects are downloadable without a JWT via /storage/v1/object/public/<bucket>/<path>. |
max_size | string | Maximum object size. Accepts KB, MB, GB suffixes. Omit to use the default 50MB limit. |
types | list | Allowed MIME types. Wildcards supported (image/*). Omit to allow all types. |
rls | list | RLS policies on storage.objects. Same syntax as table RLS. |
Buckets are managed exclusively through instancez.yaml — the migrator creates or updates them on boot.
Using from a Supabase client
Section titled “Using from a Supabase client”instancez exposes the same storage API as Supabase. Any Supabase client library works — examples below use @supabase/supabase-js:
// Uploadawait supabase.storage.from('avatars').upload('photo.png', file)await supabase.storage.from('avatars').upload('photo.png', file, { upsert: true })
// Public URL (public buckets)const { data } = supabase.storage.from('avatars').getPublicUrl('photo.png')
// Signed URL (private buckets, expires in seconds)const { data } = await supabase.storage.from('documents').createSignedUrl('report.pdf', 3600)
// Listconst { data } = await supabase.storage.from('avatars').list('', { limit: 100 })
// Deleteawait supabase.storage.from('avatars').remove(['photo.png'])Uploading to an existing path without upsert: true returns a 409 error.
Signed URLs are authorized when they are created, not when they are redeemed. createSignedUrl checks the bucket’s select policy before returning a download URL, and createSignedUploadUrl checks the insert policy before returning an upload token. If you cannot read or write an object directly, you cannot get a signed URL for it either. Redeeming the token needs no further auth (the token is the grant), so the check happens when the URL is minted.
Storage providers
Section titled “Storage providers”Local (default)
Section titled “Local (default)”providers: storage: type: local path: ./uploads # optional, defaults to ./uploadsS3-compatible
Section titled “S3-compatible”Works with AWS S3, Cloudflare R2, MinIO, Tigris, and any S3-compatible service.
providers: storage: type: s3 bucket: "${MY_S3_BUCKET}" region: "${MY_S3_REGION}" access_key_id: "${MY_S3_ACCESS_KEY_ID}" secret_access_key: "${MY_S3_SECRET_ACCESS_KEY}" endpoint: "" # optional: set for non-AWS endpoints (e.g. Cloudflare R2)Direct upload (serverless)
Section titled “Direct upload (serverless)”When using the S3 provider, you can upload files directly to S3 without routing bytes through instancez. Call POST /api/storage/<bucket>/sign to get a presigned upload URL, then PUT the file straight to S3:
const { id, upload_url } = await fetch('/api/storage/avatars/sign', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content_type: file.type, size: file.size }),}).then(r => r.json())
await fetch(upload_url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file })Use GET /api/storage/<bucket>/<id> to get a presigned download URL later.