File storage
Every project has file storage, split into buckets. Each environment has its own files: a preview environment starts empty and never sees production’s.
Buckets and rules
Two buckets always exist:
| Bucket | Read | Write |
|---|---|---|
public |
Anyone | The file’s owner |
private |
The file’s owner | The file’s owner |
Define more, or change these two, in flatstack/storage.ts. Rules use the same words as tables: owner, authenticated, public, deny, or a function.
import { bucket } from "@flatstack/server/storage";
export const avatars = bucket("avatars", {
access: { read: "public", write: "owner" },
contentTypes: ["image/*"],
maxFileBytes: 5 * 1024 * 1024,
});
export const reports = bucket("reports", {
access: { read: "authenticated", write: "deny" },
});
// A function rule gets the user and the file ({ bucket, key, size, contentType }).
export const shared = bucket("shared", {
access: { read: (_ctx, file) => file.key.startsWith("open/"), write: "owner" },
});
For files, owner means the first folder of the key is the user’s ID: user u_123 owns u_123/avatar.png and everything under u_123/. Put each user’s files under their ID.
A bucket without an access block is owner-only. Bucket names are up to 63 lowercase letters, numbers and hyphens. Every export of flatstack/storage.ts must be a bucket(), or the deploy fails.
Keys are paths like u_123/photos/beach.jpg: up to 1,000 bytes, no leading or trailing /, no //, and no . or .. folders.
Uploading
Browsers upload straight to storage with a signed URL, so large files never pass through your functions.
POST /storage/<bucket>/uploadswith{ "key", "size", "contentType"? }. The bucket’s write rule, content types and size limit are checked here.- For files up to 100 MB the answer is
{ "kind": "single", "url", "method": "PUT", "headers" }. Send onePUTtourlwith the file as the body and exactly those headers. The URL only accepts a file of the size and type you gave, and lasts an hour. - Larger files, up to 5 GB, get
{ "kind": "multipart", "uploadId", "partSize", "parts": [{ "partNumber", "url" }] }. Send eachpartSizeslice of the file (the last one shorter) as aPUTto its part URL, keep theETagheader of each response, thenPOST /storage/<bucket>/uploads/completewith{ "key", "uploadId", "parts": [{ "partNumber", "etag" }] }. To give up,POST /storage/<bucket>/uploads/abortwith{ "key", "uploadId" }. Uploads not completed within 24 hours are aborted for you.
Downloading
GET /storage/<bucket>/<key> serves the file under the bucket’s read rule. Files in a bucket anyone may read are cached at the edge for an hour (cache-control: public, max-age=3600); others are sent with private, no-store. Every response has an ETag, and If-None-Match gets 304.
For private files you can also hand out a signed link: POST /storage/<bucket>/signed-url with { "key", "expiresIn"? } returns { "url", "expiresAt" }. Links last an hour by default, at most 7 days, and work without signing in.
Image transforms
Add parameters to a download URL to resize and convert JPEG, PNG, WebP, AVIF and GIF images:
/storage/avatars/u_123/me.png?w=400&h=300&fit=cover&format=webp
| Parameter | Values |
|---|---|
w, h |
Width and height in pixels, 1 to 4096 |
fit |
scale-down, contain, cover, crop, pad |
format |
webp, avif, jpeg, png. Without it, the image keeps its format (GIFs become WebP). |
q |
Quality, 1 to 100 |
Each size is made once and cached: later requests for the same parameters, in any order, are served from the cache (x-fs-transform-cache: hit). Replacing the original makes new versions. Cached sizes don’t count towards your file storage. Source images can be up to 50 MB.
Listing and deleting
| Request | What it does |
|---|---|
GET /storage/<bucket>?prefix=&cursor=&limit=&delimiter=1 |
Files the user may read, up to 100 per page (limit up to 1,000). With delimiter=1, keys below the next / are rolled into prefixes, like folders. Returns { files, prefixes, cursor }; pass cursor back for the next page. In an owner bucket the listing is the user’s own folder. |
DELETE /storage/<bucket>/<key> |
Deletes the file if the user may write it: { deleted }. |
In functions: ctx.storage
export const saveAvatar = fn({
auth: "required",
input: z.object({ png: z.string() }),
handler: async ({ input, ctx }) => {
const bytes = Uint8Array.from(atob(input.png), (c) => c.charCodeAt(0));
const file = await ctx.storage.asUser().from("avatars").put(`${ctx.user.id}/me.png`, bytes, {
contentType: "image/png",
});
return { url: ctx.storage.from("avatars").url(file.key, { w: 256, format: "webp" }) };
},
});
ctx.storage.asUser().from(bucket) applies the bucket’s rules for the calling user, like the HTTP routes. ctx.storage.from(bucket) skips them, like ctx.db.asAdmin(): use it in server-only code. Both offer put, get (with { transform }), head, delete, list, createUploadUrl, completeUpload, abortUpload, createDownloadUrl and url. Streams passed to put need { size }.
Limits
| Limit | Value |
|---|---|
| Largest file | 5 GB |
| Single upload | up to 100 MB; larger files upload in 100 MB parts |
| Abandoned multipart uploads | aborted after 24 hours |
| Signed download links | 1 hour by default, at most 7 days |
| File storage | your plan’s file storage, pooled across the account’s projects |
When the account’s file storage is full, uploads answer 507 file_storage_full. Downloads and deletes keep working.
Errors
| Code | Status | When |
|---|---|---|
validation_failed |
400 | A bad bucket name, key, size, content type or transform parameter, or a file the bucket doesn’t accept. |
unauthenticated |
401 | The rule needs a signed-in user. |
forbidden |
403 | The rule doesn’t allow this user, for example another user’s folder in an owner bucket. |
not_found |
404 | No such bucket, file or open upload. |
file_storage_full |
507 | File storage is full. |
As Markdown: apps/storage.md