Build the product

Private Storage Uploads

Configure private S3-compatible uploads with policy, entitlement, quota, checksum, and durable deletion controls.

Synced with starter commit 7580470.

Use this when your product needs user files—avatars, documents, imports, or generated assets—without making them public or proxying every byte through the Next.js server. By the end, a signed-in user should be able to upload, download, and delete a file from Account → Files, while another organization cannot access it.

Choose the storage policy that fits your product

  • Cloudflare R2 is usually the shortest path if you want an S3-compatible service without an AWS account. AWS S3 fits an AWS-native stack. MinIO is useful for local or self-hosted environments. All three use the same adapter; switching is environment configuration.
  • Choose general for mixed files, images for image-only input with a 10 MB policy cap, documents for office/text files, or verified when the browser must provide a SHA-256 checksum.
  • The effective file limit is the smallest of the plan limit, STORAGE_MAX_UPLOAD_MB, and the selected policy. Keep the bucket private; “private” is the shipped delivery model, using short-lived signed URLs.

Ready to launch means: the UI flow works end to end, CORS accepts only the origins you operate, a direct public object URL fails, cross-organization access is rejected, and cron eventually removes a deleted object.

Set STORAGE_PROVIDER to s3, r2, or minio, then configure bucket, region, endpoint, and credentials. Objects stay private; browsers receive short-lived presigned URLs, never credentials. Configure bucket CORS for your web origin and required PUT headers, and do not enable ACLs unless the provider requires them.

Upload Contract

  1. POST /api/storage/uploads validates organization membership, storage.upload, the named policy (general, images, documents, or verified), file type, and the smallest of plan, environment, and policy size caps. It atomically reserves organization quota.
  2. The browser PUTs bytes directly to object storage. The uploader uses XHR only to expose progress.
  3. POST /api/storage/uploads/complete verifies object metadata and optional SHA-256, then marks the row active.
  4. GET /api/storage/files/[uuid]?download=1 returns an organization-scoped signed download URL.
  5. DELETE /api/storage/files/[uuid] soft-deletes the row and schedules durable storage_object_delete work.

Abandoned uploads release their reservations after one hour. Deletion retries through the job queue, so the cron runner must be operating. Object keys are generated by the service; never accept a client-selected bucket key.

Before launch, test the complete flow, wrong MIME and checksum, quota races, expired URLs, cross-organization access, deletion retry, and cleanup. See docs/storage-providers.md in the starter for provider-specific CORS and endpoint details.

Next: Configure Durable Jobs and Readiness so abandoned-upload cleanup and object deletion keep working after transient failures.

Private Storage Uploads · Sushi SaaS