Storage
Object storage for user uploads — avatars, attachments, exports — backed by S3, gated by the same Row-Level Security policies as your tables. The portal's Storage page manages buckets and objects directly.
Files could technically go in Postgres as bytea columns, and that is almost always the wrong call. A database is tuned for many small rows read transactionally; a 4 MB photo in a row bloats every backup, pushes useful data out of cache, and has to stream through your API on the way in and out. Object storage is built for the opposite shape — few, large, immutable blobs served straight to the client.
The pattern that follows is worth internalizing, because it's how essentially every production app handles uploads: the bytes go to storage, and the row in Postgres holds only the path. Your messages table stores photo_path, not the image. Queries stay small and fast, the file is served from S3 rather than through your database, and the two can be authorized independently.
What makes this practical is that access control is the same RLS you already write for tables — policies on storage.objects — so “users can only see their own files” is expressed the same way as “users can only see their own rows,” instead of being a second, differently-shaped permission system.
- User-generated media
- Avatars, post images, message attachments. Namespace the path by user id (
<uid>/<file>) so a single policy on the folder segment gives every user their own private area. - Private documents
- Invoices, contracts, medical records — a private bucket plus short-lived signed URLs, so a leaked link stops working instead of exposing the file forever.
- Generated artifacts
- CSV exports, PDF reports, thumbnails. A function writes the file with the service key and hands the user a signed URL to download it.
- Public static assets
- Logos, product images, anything cacheable and non-secret — a public bucket serves them at a stable URL with no auth round-trip. (For a whole site of files, see Websites instead.)
| Surface | Availability |
|---|---|
| Portal UI | Create/delete buckets, toggle public/private, upload/download/delete objects, generate signed URLs |
| CLI | shovelbase storage — list, create (with --public), public/private, and delete manage buckets from the terminal |
| SDK / HTTP | shovelbase.storage.* — the primary way apps read and write files |
Buckets
Create a bucket from the portal or a service-key client, and mark it public or private. Public buckets serve objects at a stable public URL with no auth; private buckets require a signed URL or a request carrying a JWT that satisfies the bucket's RLS policies. Buckets can be deleted from the portal (with everything inside them).
// Buckets are created in a migration (insert into storage.buckets) or by a// service-key client:await admin.storage.createBucket('avatars', { public: false });Objects: upload, download, list
await shovelbase.storage.from('avatars').upload(`${user.id}.png`, file);const { data: blob } = await shovelbase.storage.from('avatars').download(`${user.id}.png`);const { data: files } = await shovelbase.storage.from('avatars').list();Uploads are capped at 50 MB per object. The portal's Storage page does the same by hand: browse a bucket, upload a file, download or delete an existing object.
The usual pattern: upload, then store the path
Upload first, and only write the row once you have a path — that way a failed upload never leaves a row pointing at a file that doesn't exist. Note the path is namespaced by user id, which is what makes a per-user policy possible, and that the random suffix keeps a second upload from silently overwriting the first:
const { data: { user } } = await shovelbase.auth.getUser(); // 1. Bytes → storage, under a path this user is allowed to write.const path = `${user.id}/${crypto.randomUUID()}.png`;const { error: uploadError } = await shovelbase.storage .from('photos') .upload(path, file, { contentType: file.type });if (uploadError) throw uploadError; // 2. Path → Postgres. The row stays tiny; the image never touches the DB.await shovelbase.from('messages').insert({ body: text, photo_path: path }); // 3. Reading back: build the URL from the stored path.const { data } = shovelbase.storage.from('photos').getPublicUrl(path);Store the path, not the full URL. Paths stay valid if the bucket's visibility changes, if you attach a custom domain, or if a private bucket later needs signed URLs — a full URL baked into a row has to be rewritten in all of those cases.
Uploading without trusting the client: signed upload URLs
A public bucket only relaxes reads. Every write still runs an insert against storage.objects under RLS, so a browser using the anon key gets new row violates row-level security policy unless a policy grants that write. When the writer can be trusted with a blanket grant — an authenticated user writing under their own <uid>/ folder, per the insert policy below — that policy is all you need and upload() works directly from the client.
When it can't — an anonymous uploader, or an authorization rule too involved for a policy's with check — the write has to be authorized by a server holding the service key. The wrong way is to stream the file through a function: the bytes go browser → your function → storage, bounded by the function's memory and time. Instead, have the server mint a signed upload URL — a short-lived token scoped to one path — and hand it back. The client uploads the bytes against that token, and your function only ever sees the tiny request that mints it:
// Server (service key): decide the path however you like, then mint a one-time// upload URL. No file bytes touch this code.const path = `${crypto.randomUUID()}.png`;const { data } = await admin.storage .from('uploads') .createSignedUploadUrl(path); // { signedUrl, token, path } // Client (anon): bytes go straight to storage, not through your function.await shovelbase.storage .from('uploads') .uploadToSignedUrl(data.path, data.token, file);The 50 MB per-object cap applies here too — a signed upload URL is still a single request, not a way around the limit. The token authorizes exactly one path, so mint it only after you've decided where the file is allowed to land.
Sharing files: public URLs vs. signed URLs
For a public bucket, a public URL works for anyone, forever:
const { data } = shovelbase.storage.from('avatars').getPublicUrl(`${user.id}.png`);For a private bucket, generate a time-limited signed URL instead — the portal's Storage page can generate one too, for sharing a single file without making the whole bucket public:
const { data: signed } = await shovelbase.storage .from('avatars') .createSignedUrl(`${user.id}.png`, 3600); // seconds until it expiresAccess control
Beyond the bucket's public/private flag, fine-grained access is RLS policies on storage.objects — the same permissions model as any table, written in a migration or the SQL Editor:
create policy "anyone can view photos" on storage.objects for select to anon, authenticated using (bucket_id = 'photos'); create policy "you can upload your own photo" on storage.objects for insert to authenticated with check ( bucket_id = 'photos' and (storage.foldername(name))[1] = auth.uid()::text );HTTP API (/storage/v1)
curl -X POST "https://<ref>.shovelbase.com/storage/v1/object/avatars/me.png" \ -H "Authorization: Bearer $USER_JWT" \ -H "Content-Type: image/png" --data-binary @me.png curl "https://<ref>.shovelbase.com/storage/v1/object/avatars/me.png" \ -H "Authorization: Bearer $USER_JWT" -o me.pngSee shovelbase-js for the full shovelbase.storage client API, and Logs for where storage requests show up.