Skip to content

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.

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"
KeyTypeDescription
publicboolWhen true, objects are downloadable without a JWT via /storage/v1/object/public/<bucket>/<path>.
max_sizestringMaximum object size. Accepts KB, MB, GB suffixes. Omit to use the default 50MB limit.
typeslistAllowed MIME types. Wildcards supported (image/*). Omit to allow all types.
rlslistRLS policies on storage.objects. Same syntax as table RLS.

Buckets are managed exclusively through instancez.yaml — the migrator creates or updates them on boot.

instancez exposes the same storage API as Supabase. Any Supabase client library works — examples below use @supabase/supabase-js:

// Upload
await 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)
// List
const { data } = await supabase.storage.from('avatars').list('', { limit: 100 })
// Delete
await 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.

providers:
storage:
type: local
path: ./uploads # optional, defaults to ./uploads

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)

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.

  • RLS — write the policies that gate storage.objects access
  • Functions — process uploads server-side with ctx.serviceClient