Skip to content

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.

  1. POST /storage/<bucket>/uploads with { "key", "size", "contentType"? }. The bucket’s write rule, content types and size limit are checked here.
  2. For files up to 100 MB the answer is { "kind": "single", "url", "method": "PUT", "headers" }. Send one PUT to url with 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.
  3. Larger files, up to 5 GB, get { "kind": "multipart", "uploadId", "partSize", "parts": [{ "partNumber", "url" }] }. Send each partSize slice of the file (the last one shorter) as a PUT to its part URL, keep the ETag header of each response, then POST /storage/<bucket>/uploads/complete with { "key", "uploadId", "parts": [{ "partNumber", "etag" }] }. To give up, POST /storage/<bucket>/uploads/abort with { "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