Recipe: Add S3-compatible uploads
Let users upload files directly to an S3-compatible bucket (Cloudflare R2, AWS S3, Backblaze B2, or MinIO). The browser talks to the bucket; the API only signs URLs and records metadata.
Estimated time: 60 minutes.
Overview
Section titled “Overview”- Add S3 credentials to env.
- Install AWS SDK in the API.
- Scaffold an uploads feature.
- Implement sign and finalize endpoints.
- Add a media table.
- Wire the UI.
1. Add S3 credentials
Section titled “1. Add S3 credentials”echo 'S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com' >> compose/.envecho 'S3_REGION=auto' >> compose/.envecho 'S3_BUCKET=your-bucket-name' >> compose/.envecho 'S3_ACCESS_KEY_ID=...' >> compose/.envecho 'S3_SECRET_ACCESS_KEY=...' >> compose/.envecho 'S3_PUBLIC_BASE_URL=https://files.example.com' >> compose/.envFor Cloudflare R2, the endpoint is https://<account-id>.r2.cloudflarestorage.com and region is auto. For AWS S3, use your region like us-east-1.
2. Install the AWS SDK
Section titled “2. Install the AWS SDK”cd apps/api && bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner3. Scaffold the uploads feature
Section titled “3. Scaffold the uploads feature”cd apps/api && bun run new:resource uploadsThis writes uploads.routes.ts, uploads.service.ts, uploads.types.ts and registers them in config/routes.ts.
4. Implement the sign endpoint
Section titled “4. Implement the sign endpoint”In uploads.service.ts, sign a PUT URL:
import { PutObjectCommand } from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export async function signUploadUrl( accountId: string, mimeType: string,): Promise<{ uploadUrl: string; objectKey: string; publicUrl: string }> { const objectKey = `${accountId}/${crypto.randomUUID()}`;
const command = new PutObjectCommand({ Bucket: env.S3_BUCKET, Key: objectKey, ContentType: mimeType, });
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 300, // 5 minutes });
const publicUrl = `${env.S3_PUBLIC_BASE_URL}/${objectKey}`; return { uploadUrl, objectKey, publicUrl };}Add the routes to uploads.routes.ts:
POST /api/v1/uploads/sign → calls signUploadUrl() → returns { uploadUrl, objectKey, publicUrl }
POST /api/v1/uploads/finalize → inserts the media row → returns the media record5. Add a media table
Section titled “5. Add a media table”In src/clients/postgres/schema/app.schema.ts:
export const media = pgTable("media", { id: uuid("id").primaryKey().defaultRandom(), account_id: uuid("account_id") .notNull() .references(() => accounts.id), object_key: text("object_key").notNull(), mime_type: text("mime_type").notNull(), size_bytes: integer("size_bytes").notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(),});Generate the migration:
bun run db:generate6. Wire the UI
Section titled “6. Wire the UI”In the UI component:
// Step 1: get a signed URLconst { uploadUrl, objectKey, publicUrl } = await apiClient.POST( "/api/v1/uploads/sign", { body: { mime: file.type, size: file.size } });
// Step 2: upload the file directly to the bucketawait fetch(uploadUrl, { method: "PUT", body: file });
// Step 3: finalize in the databaseawait apiClient.POST("/api/v1/uploads/finalize", { body: { objectKey, mimeType: file.type, sizeBytes: file.size }});Verify
Section titled “Verify”- Browser network tab: the PUT request goes to the S3 endpoint, not your API.
- Bucket dashboard: new object appears at the key you chose.
- Database:
SELECT * FROM media ORDER BY created_at DESC LIMIT 5; - Audit log:
SELECT * FROM audit.audit_log WHERE event = 'media.upload_finalized' LIMIT 5;
Key points
Section titled “Key points”- CORS: The bucket must allow PUT from your origin. Test with:
curl -X OPTIONS -H "Origin: https://example.com" https://your-bucket/... - Public URLs: If you set
S3_PUBLIC_BASE_URLto a public bucket, anyone with the URL can download. For sensitive files, use signed-URL GETs instead. - Presigned URL TTL: 5 minutes is standard. Adjust if users need more time to upload large files.
Related
Section titled “Related”- Env validator - declare S3 vars in the schema.
- API overview - route/service/types pattern.
- Multi-tenant model - scoping uploads by accountId.