---
title: "Amazon S3"
description: "Amazon S3 and S3-compatible stores (R2, MinIO, B2) as the planned MainSDK files driver, with bucket setup, direct browser uploads and an example."
section: "integrations"
canonical: "https://mainsdk.dev/docs/integrations/amazon-s3"
updated: "2026-10-28"
---

# 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.

Canonical: https://mainsdk.dev/docs/integrations/amazon-s3 · Updated: 2026-10-28

Amazon S3 is object storage. The `s3` driver for [`files`](https://mainsdk.dev/docs/interfaces/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.

> **Note:** `files` is being built for 0.3 and this driver is built with it (spec `files-share`, ADR-0013). It runs from the repository and is not published to npm yet.

## Interfaces it serves

### File uploads (`files`)

- Status: Building for 0.3 · ships as a stable API
- Driver: `s3` (Amazon S3 and S3-compatible stores (R2, MinIO, B2))
- Docs: https://mainsdk.dev/docs/interfaces/files.md

```yaml
files:
  driver: s3
```

Swap to another provider: `getqr`, `uploadthing`, `vercel-blob`, `local`.

## 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:

```json title="bucket CORS"
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]
```

4. 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

```yaml title="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:

```yaml title="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:

```ts title="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);
}
```

```ts title="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](https://mainsdk.dev/docs/guides/file-uploads). 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

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-FILES-0002` | `files.url()` was called for a reader `access` denies (403). | Read as the owner, or add an `access` rule for the reader. |
| `MAIN-FILES-0005` | The 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-0010` | `mainsdk 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-0004` | `key` 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](https://mainsdk.dev/docs/guides/swap-a-driver).
