Skip to content
BoringStack
GitHub

Recipe: Add S3-compatible uploads

4 min read

Verified 2026-05

Recipes

Add direct-to-bucket file uploads from the browser with S3 presigned URLs. File bytes stay off your API servers.

60 min

Estimated duration

S3 API

Protocol

R2 / S3

Backends

Let an authenticated user upload a file straight from the browser into an S3-compatible bucket. The bytes never touch the API host; the API only signs a short-lived PUT URL and records the resulting object in Postgres.

  • A working local stack.
  • A bucket with S3-compatible credentials. Recommended: Cloudflare R2, which has zero egress fees and the same auth model as S3. AWS S3, Backblaze B2, and a local MinIO container also work.
  • The bucket configured with a CORS rule that allows your origin to PUT.
  1. Add bucket credentials to compose/.env:

    Terminal window
    S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com # or AWS region URL
    S3_REGION=auto # or us-east-1 etc.
    S3_BUCKET=your-bucket-name
    S3_ACCESS_KEY_ID=...
    S3_SECRET_ACCESS_KEY=...
    S3_PUBLIC_BASE_URL=https://files.example.com # CDN-fronted public URL, optional

    Add them to src/config/env.schema.ts so the env validator refuses to boot if they’re missing in prod.

  2. Add the AWS SDK to the API:

    Terminal window
    cd apps/api && bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
  3. Create a new feature folder. Use the scaffold so the layer split is correct from the start:

    Terminal window
    cd apps/api && bun run new:resource uploads

    That writes uploads.routes.ts, uploads.service.ts, uploads.types.ts and wires the routes into config/routes.ts.

  4. In uploads.service.ts, sign a PUT URL keyed by accountId + uuid. Return { uploadUrl, objectKey, publicUrl }. Use @aws-sdk/s3-request-presigner getSignedUrl(...) with a 5-minute TTL.

  5. Add a media table to the schema. Minimum columns: id, account_id, object_key, mime_type, size_bytes, created_at. After a successful PUT, the UI POSTs a finalize request to the API which inserts the row inside a transaction.

  6. UI side. The UI app’s lib/api client already has the typed routes (regenerate with bun run generate:api). A simple file picker:

    const { uploadUrl, objectKey, publicUrl } =
    await apiClient.POST("/api/uploads/sign", { body: { mime, size } });
    await fetch(uploadUrl, { method: "PUT", body: file });
    await apiClient.POST("/api/uploads/finalize", { body: { objectKey } });
  • Browser DevTools network tab: the PUT goes straight to your bucket origin, not to your API host.

  • The bucket dashboard shows the new object under <accountId>/<uuid> (or whatever key pattern you chose).

  • A row appears in the media table:

    SELECT id, account_id, object_key, mime_type FROM media ORDER BY created_at DESC LIMIT 5;
  • The audit log shows media.upload_finalized.

  • apps/api/src/config/env.schema.ts: add the six S3 env vars to the TypeBox env shape.
  • apps/api/src/api/uploads/: new feature folder, follows the route/service/types split.
  • apps/api/src/clients/postgres/schema/media.ts: new Drizzle table.
  • apps/api/drizzle/0xxx_add_media_table.sql: generated migration (bun run db:generate).
  • apps/ui/src/features/<wherever-you-need-uploads>/: picker + progress UI calling the typed client.