obsidian/wiki/payloadcms/storage-adapters.md
2026-05-15 16:54:04 +01:00

6.8 KiB

title aliases tags sources created updated
Storage Adapters
payload-storage
cloud-storage
s3-storage
gcs-storage
azure-storage
payloadcms
upload
storage
s3
gcs
azure
vercel
cloudflare
r2
raw/upload__storage-adapters.md
2026-05-15 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

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

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
  • 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).

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

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

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

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

Cloudflare R2 (Workers native)

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:

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

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

Implement the GeneratedAdapter interface:

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

Sources