MainSDK
Search⌘K
Integrations

Amazon S3

Amazon S3 and S3-compatible stores (R2, MinIO, B2) as the planned MainSDK files driver, with bucket setup, direct browser uploads and an example.

1 interface1 driverWritten guide
Updated 2026-10-28

Amazon S3 is object storage. The s3 driver for files works with S3 itself and with stores that speak the S3 API, such as Cloudflare R2, MinIO and Backblaze B2. You would pick it when you already have a bucket, need its region, or want your files in your own cloud account.

Interfaces it serves

Pick Amazon S3 in main.yaml for any of these. Your calls stay the same, and you can swap later.

Before you start

  1. Create a bucket with Block all public access on. MainSDK never serves files from a public bucket.
  2. Create an access key limited to that bucket (s3:PutObject, s3:GetObject, s3:DeleteObject on arn:aws:s3:::acme-uploads/*).
  3. Allow your site to upload from the browser. Add this CORS rule to the bucket:
bucket CORS
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]
  1. Put the secret key in your secrets manager as S3_SECRET_ACCESS_KEY. On AWS you can skip the key and use the runtime's IAM role.

Configure

main.yaml
files:
  driver: s3
  bucket: acme-uploads
  region: eu-west-1                 # the bucket's own region
  access-key-id: AKIA…                # an identifier, not a secret
  key: secret:S3_SECRET_ACCESS_KEY
  scan: true
  max-size: 25MB
  • access-key-id is an identifier, so it may sit in the file as a literal. key is the secret and must be a secret: reference.
  • On AWS, omit both access-key-id and key to use the runtime's IAM role. With neither set, the driver uses role credentials and reads credentials from nowhere else.
  • region is the bucket's region. The eu region in the rest of these docs is MainSDK's data region for MainSDK's own data, and it does not move your files. Pick a bucket in the region your users' data must stay in.

For an S3-compatible store add its endpoint:

main.yaml
files:
  driver: s3
  bucket: acme-uploads
  endpoint: https://<account>.r2.cloudflarestorage.com   # R2; MinIO and B2 use their own URL
  access-key-id: <access key id>
  key: secret:S3_SECRET_ACCESS_KEY

Use it

Browsers upload straight to the bucket on a signed, single-use URL; your server never carries the bytes. Issue the upload on the server:

app/api/uploads/route.ts
import { auth, files } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const upload = await files.uploadUrl({
    owner: session.user,
    maxSize: "5MB",
    types: ["image/png", "image/jpeg"],
  });
  return Response.json(upload);
}
app/api/uploads/[id]/complete/route.ts
import { auth, files } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;                           // upload.ref.id
  const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it
  const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
  return Response.json({ url });
}

The browser sends the file to upload.url with upload.method, then calls the complete route. The browser function, the Hono version and the offline test are in Upload files with S3, Uploadthing or Vercel Blob. A 6 MB file or a text/html file sent to the URL above is refused by the store, and the file is not readable until files.complete() has run the scan.

Local development and testing

Use the local driver in development and createTestApp() from @mainsdk/testing in tests; neither needs a bucket. To test against the real driver offline, run MinIO and point endpoint at it in main.env.dev.yaml.

What MainSDK adds on top

  • Owner-scoped reads: a signed URL is never issued for another user's file.
  • A scan before a file is readable, and resumable, idempotent uploads.
  • Short-lived URLs with a size and type limit on every upload URL.
  • mainsdk check fails if route code imports @aws-sdk/client-s3 directly; files.raw() gives you the client and is listed as outside the guarantees.

Verify

Run npx mainsdk check --env prod. A pass exits 0 and names no MAIN-FILES or MAIN-CONFIG finding.

If it fails

CodeCauseFix
MAIN-FILES-0002files.url() was called for a reader access denies (403).Read as the owner, or add an access rule for the reader.
MAIN-FILES-0005The scan in files.complete() found an infected file. The upload fails and the file is quarantined.Ask the user for a clean file. Nothing to fix in code.
MAIN-FILES-0010mainsdk check found @aws-sdk/client-s3 imported in route code.Use files.uploadUrl() and files.complete(), and keep the store as the files driver.
MAIN-CONFIG-0004key is a literal value, not a secret: reference.Move the value to your secrets manager and write secret:S3_SECRET_ACCESS_KEY.

For agents

text
Goal: use s3 as the files driver and add owner-scoped uploads to this app.
Read: docs/specs/files-share.md, this page, main.yaml.
Touch only: main.yaml, main.env.dev.yaml, app/api/uploads/**, one test file.
Do: set files.driver to s3 with key: secret:S3_SECRET_ACCESS_KEY;
issue uploads with files.uploadUrl({ owner, maxSize, types });
send the file to upload.url with upload.method; call files.complete(upload.ref.id, { owner }) as the same user, then files.url(ref, { for, expiresIn }).
Verify: npx mainsdk check --json --agent
Never weaken, skip or loosen a check to make it pass.
Never import @aws-sdk/client-s3 in route code.

Swap it

Change driver to getqr, uploadthing or vercel-blob. Your calls stay the same; existing files stay where they are until you migrate them. See Swap a driver.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet