GenerateSaaS

File Storage

Upload avatars and files to S3 or local disk with quotas, rate limiting, and magic-byte validation.

@repo/storage handles file uploads behind one provider-agnostic API, gated by config.storage.enabled in packages/config/src/index.ts. When enabled it persists files to an S3-compatible bucket or the local filesystem; when off, isStorageEnabled() returns false, POST /storage/upload responds 503, and upload UI is hidden.

What you get

  • Avatar uploads: uploadAvatar() with per-user mutex, atomic swap, and rollback on DB failure.
  • Quotas: per-user daily upload cap (429 over limit; admins bypass).
  • Rate limiting: per-IP burst limit on the upload endpoint.
  • MIME + magic-byte validation: only real jpeg/png/gif/webp pass; renamed files are rejected.
  • Public vs private: public files get a URL; private files (the default) return url: null.

Configuration

config.storage is a discriminated union - { enabled: false } or the full shape. The shipped default is on:

storage: { enabled: true, provider: "s3", maxFileSizeMB: 5, dailyUploadLimit: 20 }
KeyTypeDefaultDescription
enabledbooleantrueMaster switch. When false, uploads 503 and UI hides.
provider"s3" | "local""s3"Storage backend (see below).
maxFileSizeMBnumber5Per-upload cap; exceeding returns 413.
dailyUploadLimitnumber20Per-user uploads per UTC day; exceeding returns 429.

Credentials live in environment variables, not config - see Environment variables.

Providers

ProviderBacksClientPublic URL source
s3AWS S3, R2, MinIO, Backblaze (any S3-compatible)s3miniSTORAGE_PUBLIC_URL (CDN) → endpoint
locallocal filesystem diskNode fsSTORAGE_LOCAL_PUBLIC_URLGET /storage/files/*
  • s3 reads STORAGE_REGION, STORAGE_ENDPOINT, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, STORAGE_BUCKET_NAME, plus optional STORAGE_PUBLIC_URL (CDN) and STORAGE_FORCE_PATH_STYLE (MinIO/path-style buckets).
  • local reads STORAGE_LOCAL_PATH (default ../../data) and optional STORAGE_LOCAL_PUBLIC_URL; LocalProvider serves only the public/ prefix and blocks path-traversal, absolute-path, and null-byte attacks.

The local provider writes to disk via Node fs, so it is not viable on platforms with read-only or ephemeral filesystems (most serverless hosts). Use provider: "s3" there - see Vercel deployment.

Upload endpoint

The shared backend exposes POST /storage/upload, guarded by authGuard and a per-IP rate limiter (10 requests / 60s). It accepts multipart/form-data with a file field plus optional folder, then enforces, in order:

CheckFailureNotes
Storage enabled503config.storage.enabled === false
Daily quota429Per-user; admins bypass
maxFileSizeMB413Per-upload byte cap
Allowed MIME type400jpeg, png, gif, webp only
Magic bytes400validateMagicBytes() confirms the bytes match the declared type
Concurrent avatar upload409Mutex already held for this user

A folder of avatars routes to uploadAvatar() (per-user mutex + atomic DB swap); any other folder lands under {userId}/{folder}. Endpoint uploads are always public - they return a served URL.

Programmatically, uploadFile(file, filename, contentType, { folder, visibility }) defaults to visibility: "private" (returns url: null); pass "public" to land under a public/ prefix and get a URL.

Rate limiting and quotas

Two Redis-backed limits, keyed from cacheConfig in packages/config/src/cache.ts:

LimitConfig keyRedis keyWindow / TTLCap
Per-IP burstcacheConfig.uploadRateLimitupload:rate:{ip}60s window10 / window (route constant)
Per-user dailycacheConfig.uploadDailyQuotaupload:quota:{userId}:{date}24h TTLconfig.storage.dailyUploadLimit (20)

checkDailyUploadQuota() increments the counter and compares it against dailyUploadLimit. See Caching for the Redis store wiring.

Frequently asked questions

Why does uploadFile() return url: null? The programmatic helper defaults to visibility: "private". Pass { visibility: "public" } to land under the public/ prefix and get a served URL. (The POST /storage/upload endpoint always uploads public, so it never returns null.)

Can I switch from local to s3 after launch? Yes - flip config.storage.provider and set the STORAGE_* env vars. Old keys keep their paths; deleteFileFromAllProviders() cleans up across both during the transition.

How are images resized? Avatars are resized and center-cropped to 256x256 WebP on the client before upload, so no server-side image library runs in the request path.

On this page