---
title: "Storage Adapters"
aliases: [payload-storage, cloud-storage, s3-storage, gcs-storage, azure-storage]
tags: [payloadcms, upload, storage, s3, gcs, azure, vercel, cloudflare, r2]
sources: [raw/upload__storage-adapters.md]
created: 2026-05-15
updated: 2026-05-15
---

# Storage Adapters

Payload ships with local disk storage by default. For production, swap to a cloud adapter — all adapters automatically set `disableLocalStorage: true` on the configured collections.

## Available Adapters

| Service | Package |
|---------|---------|
| Vercel Blob | `@payloadcms/storage-vercel-blob` |
| AWS S3 | `@payloadcms/storage-s3` |
| Azure Blob | `@payloadcms/storage-azure` |
| Google Cloud Storage | `@payloadcms/storage-gcs` |
| Uploadthing | `@payloadcms/storage-uploadthing` |
| Cloudflare R2 (Workers) | `@payloadcms/storage-r2` |

## Vercel Blob

```ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'

vercelBlobStorage({
  enabled: true,
  collections: {
    media: true,
    'media-with-prefix': { prefix: 'my-prefix' },
  },
  token: process.env.BLOB_READ_WRITE_TOKEN,
})
```

- Requires `BLOB_READ_WRITE_TOKEN` (auto-set by Vercel after adding blob storage)
- Vercel server upload limit: **4.5 MB** — use `clientUploads: true` to bypass

| Option | Default |
|--------|---------|
| `enabled` | `true` |
| `addRandomSuffix` | `false` |
| `cacheControlMaxAge` | 1 year |
| `clientUploads` | — |
| `useCompositePrefixes` | `false` |

## AWS S3

```ts
import { s3Storage } from '@payloadcms/storage-s3'

s3Storage({
  collections: { media: true },
  bucket: process.env.S3_BUCKET,
  config: {
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    },
    region: process.env.S3_REGION,
  },
})
```

- `config` accepts any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3)
- `signedDownloads` — use presigned URLs per-collection (useful for large files/videos)
- `enabled: Boolean(process.env.S3_BUCKET)` — skip plugin when credentials absent in local dev

### Cloudflare R2 via S3 API

Use `@payloadcms/storage-s3` for R2 from Node.js/Vercel/Netlify — **not** `@payloadcms/storage-r2` (that's Cloudflare Workers only).

```ts
s3Storage({
  enabled: Boolean(process.env.R2_BUCKET),
  collections: {
    media: {
      disablePayloadAccessControl: true,
      generateFileURL: ({ filename, prefix }) => {
        const key = prefix ? `${prefix}/${filename}` : filename
        return `${process.env.R2_PUBLIC_URL}/${key}`
      },
    },
  },
  bucket: process.env.R2_BUCKET,
  config: {
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY_ID,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
    },
    region: 'auto',           // required by R2
    endpoint: process.env.R2_ENDPOINT,  // S3 API endpoint, uploads only
    forcePathStyle: true,     // required for R2
  },
})
```

Required env vars:
```
R2_BUCKET=my-bucket
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_ENDPOINT=https://<accountId>.r2.cloudflarestorage.com
R2_PUBLIC_URL=https://media.yourdomain.com   # R2.dev subdomain or custom domain
```

> **Warning:** R2 buckets are private by default. The S3 API endpoint is for uploads only — you must enable the R2.dev subdomain or connect a custom domain to serve files publicly.

## Azure Blob Storage

```ts
import { azureStorage } from '@payloadcms/storage-azure'

azureStorage({
  collections: { media: true },
  allowContainerCreate: process.env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
  baseURL: process.env.AZURE_STORAGE_ACCOUNT_BASEURL,
  connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
  containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
})
```

## Google Cloud Storage

```ts
import { gcsStorage } from '@payloadcms/storage-gcs'

gcsStorage({
  collections: { media: true },
  bucket: process.env.GCS_BUCKET,
  options: {
    apiEndpoint: process.env.GCS_ENDPOINT,
    projectId: process.env.GCS_PROJECT_ID,
  },
})
```

## Uploadthing

```ts
uploadthingStorage({
  collections: { media: true },
  options: {
    token: process.env.UPLOADTHING_TOKEN,
    acl: 'public-read',
  },
})
```

## Cloudflare R2 (Workers native)

```ts
r2Storage({
  collections: { media: true },
  bucket: cloudflare.env.R2,  // native R2 bucket binding
})
```

> Beta. Use this only in Cloudflare Workers where R2 is available as a native binding. For all other Node.js environments, use `@payloadcms/storage-s3` with the R2 S3-compatible endpoint.

## Prefix Composition

By default, a document-level prefix **overrides** the collection prefix entirely.

With `useCompositePrefixes: true`, they are **combined**:

```
# Default
Collection prefix: media-folder, Document prefix: user-123
→ user-123/image.jpg

# useCompositePrefixes: true
→ media-folder/user-123/image.jpg
```

## Payload Access Control

By default, Payload proxies all file requests through `/<collectionSlug>/<staticURL>/<filename>` so that access control rules apply even to cloud-hosted files. The file URL does **not** point directly to the cloud host.

Set `disablePayloadAccessControl: true` on a collection to bypass the proxy and serve files directly from the cloud host. Only safe when `read: () => true` or similar.

## Custom Adapter

Use `@payloadcms/plugin-cloud-storage` as the base:

```ts
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'

cloudStoragePlugin({
  collections: {
    'my-collection-slug': { adapter: myCustomAdapter },
  },
})
```

Implement the `GeneratedAdapter` interface:

```ts
interface GeneratedAdapter {
  fields?: Field[]
  generateURL?: GenerateURL
  handleDelete: HandleDelete
  handleUpload: HandleUpload
  name: string
  onInit?: () => void
  staticHandler: StaticHandler
}
```

## Key Takeaways

- All official adapters auto-set `disableLocalStorage: true` on their collections
- **Vercel 4.5 MB limit** — set `clientUploads: true` + allow CORS PUT on your bucket
- **R2 + Node.js** → use `storage-s3` with `region: 'auto'`, `forcePathStyle: true`, `endpoint` for uploads, separate `R2_PUBLIC_URL` for serving
- `useCompositePrefixes: true` combines collection + document prefixes instead of document overriding collection
- Access control proxying is on by default — disable only when collection is fully public
- Conditionally enable with `enabled: Boolean(process.env.BUCKET)` to skip in local dev

## Related

- [[wiki/payloadcms/upload|Upload & Media]] — upload collection config, imageSizes, focal point
- [[wiki/payloadcms/production-deployment|Production Deployment]] — ephemeral vs persistent file storage, Dockerfile
- [[wiki/payloadcms/access-control|Access Control]] — read access applied to static file serving
- [[wiki/payloadcms/plugin-form-builder|Form Builder Plugin]] — presigned URL pattern for form uploads

## Sources

- `raw/upload__storage-adapters.md`
- https://payloadcms.com/docs/upload/storage-adapters
