Advanced Strapi v5 upload provider for Cloudflare R2 with multi-bucket & path support.
npm install strapi-provider-cloudflare-r2-advanced


---
strapi-provider-cloudflare-r2-advanced is a production-ready upload provider for Strapi v5, designed to integrate seamlessly with Cloudflare R2 (S3-compatible object storage).
It offers advanced capabilities beyond standard S3 providers:
- Multi-bucket support (public, private, custom separation)
- Automatic signed URLs for private buckets
- Secure private/public domain routing
- True Cloudflare R2 compatibility
- Full image format deletion (thumbnail, small, medium, large)
- Streaming uploads using AWS SDK v3
- Clean TypeScript implementation
- Non-breaking replacement of existing S3 or R2 providers
Its not fully battle tested but is working right now, please open issues if you find some.
---
This provider was initially inspired by the community strapi-provider-cloudflare-r2, but has been significantly extended and rewritten for advanced multi-bucket support, private/public logic, and seamless compatibility with Strapi v5 and AWS SDK v3.
Mainly because i needed multiple buckets.
> β οΈ Warning:
> This provider currently supports only a single set of S3 (Cloudflare R2) credentials.
> You cannot configure different API keys or accounts per bucket; all buckets must live under the same Cloudflare R2 account and credentials.
>
> _If you require true per-bucket credential isolation, open an issue to discuss the use-case!_
---
``bash`
npm install strapi-provider-cloudflare-r2-advancedor
yarn add strapi-provider-cloudflare-r2-advanced
---
Create or modify:
``
/config/plugins.ts
`ts
export default () => ({
upload: {
config: {
provider: "strapi-provider-cloudflare-r2-advanced",
providerOptions: {
endpoint: env("CF_ENDPOINT"), // Example: "https://
// Optional internal prefix for all stored R2 object keys
// If rootPath = "v1/uploads", your files will be stored like:
// v1/uploads/company/123/file.jpg
rootPath: null,
// Optional override for the returned PUBLIC URLs (applies only to buckets listed in publicDomains)
// If baseUrl = "https://cdn.example.com/assets", final URLs become:
// https://cdn.example.com/assets/company/123/file.jpg
baseUrl: null,
/**
* Cloudflare R2 Credentials
* Obtain these at:
* https://dash.cloudflare.com/[your-account-id]/r2/api-tokens
*/
accessKeyId: env("R2_ACCESS_KEY_ID"),
secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
/**
Bucket routing by logical* name.
*
* IMPORTANT:
* These names are NOT special β "public" / "private" are NOT reserved.
* You can choose ANY bucket name, e.g. "uploads", "invoices", "tenantAssets".
*
The privacy* of a bucket depends ONLY on whether it has a corresponding entry
* inside publicDomains.
*/
buckets: {
uploads: env("CF_BUCKETS_UPLOADS"), // logicalName: actualBucketName
internalAssets: env("CF_BUCKETS_INTERNAL_ASSETS")
},
/**
* Public CDN domains
*
* A bucket becomes PUBLIC if (and only if) it appears in this object.
* If a bucket key does NOT exist here -> it becomes PRIVATE and uses SIGNED URLs.
*
* TIP:
Use environment variables prefixed with CF_PUBLIC_ACCESS_URL_
* (Important to correctly generate security middleware)
*/
publicDomains: {
uploads: env("CF_PUBLIC_ACCESS_URL_UPLOADS") // Only 'uploads' bucket is public
},
// Default bucket if none is matched via prefix or file path
defaultBucket: "uploads",
// Signed URL TTL (applies only to private buckets)
signedUrlExpires: 3600
}
}
}
});
`
A minimal example of uploading from your frontend (Nuxt/Vue, React, plain JS, etc.):
`ts
// Example: Nuxt/Vue Composition API
const file = ref
async function upload() {
const formData = new FormData();
// The important part: include your desired path
// This determines bucket + folder routing:
// Example: bucket:public:company/123/logos
formData.append("path", "bucket:public:company/123/logos");
// The actual file (or multiple)
formData.append("files", file.value as File);
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const uploaded = await res.json();
console.log("Uploaded:", uploaded);
}
`
This works because Strapiβs Upload plugin internally reads path and files from the multipart payload, and the provider determines:
- which bucket to use
- file destination path
- whether signed or public URLs should be generated
When using public CDN domains for Cloudflare R2, make sure Strapi's Content-Security-Policy (CSP) allows images and media from those domains.
Add this to your config/middlewares.ts:
`ts
export default ({ env }) => {
const prefix = 'CF_PUBLIC_ACCESS_URL_';
// Extract domain hostnames from env vars:
const domains = Object.keys(process.env)
.filter(key => key.startsWith(prefix))
.map(key => process.env[key])
.filter(Boolean)
.map((domain: string) => domain.replace(/^https?:\/\//, ""));
return [
'strapi::logger',
'strapi::errors',
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"connect-src": ["'self'", "https:"],
"img-src": [
"'self'",
"data:",
"blob:",
"market-assets.strapi.io",
...domains
],
"media-src": [
"'self'",
"data:",
"blob:",
"market-assets.strapi.io",
...domains
],
upgradeInsecureRequests: null,
},
},
},
},
// ... rest of middleware stack
];
};
`
This ensures the Media Library UI and frontend can display files hosted on any public R2 bucket domain listed under your CF_PUBLIC_ACCESS_URL_* environment variables. (Images/Files from private buckets will not have a preview in the Media Library)
---
Bucket selection is based on:
1. bucket: prefix found in file.path providerOptions.buckets
2. defaultBucket
3.
Example path:
``
bucket:private:company/123/invoices
This file will always use the private bucket.
---
Public bucket example:
``
https://cdn.example.com/company/123/file.jpg
Private bucket example:
Uses signed URLs generated via AWS SDK v3:
``
https://
---
You can manually request a signed URL using:
`ts`
const url = await strapi
.plugin("upload")
.provider.getSignedUrl(file);
Private files always return signed URLs.
Public files never return signed URLs.
---
Strapi often generates image formats:
- thumbnailsmall
- medium
- large
-
This provider deletes all formats, not just the main file.
Use Strapiβs own service:
`ts`
await strapi
.plugin("upload")
.service("upload")
.remove(file);
This:
- Deletes the main R2 object
- Deletes all resized formats
- Removes DB entry
- Unlinks from related entities
- Cleans Media Library automatically
You should NOT call provider.delete() directly.
---
On each file Strapi stores:
`json`
{
"bucket": "private",
"key": "company/abc123/file.jpg",
"isPrivate": true
}
Formats include their own metadata as well.
---
Install dependencies:
`bash`
npm install
npm test
(Tests are scaffold-ready; add more for your use-case.)
---
``
strapi-provider-cloudflare-r2-advanced/
βββ src/
β βββ index.ts # Provider implementation
βββ dist/ # Compiled output
βββ tests/ # Basic test suite
βββ package.json
βββ tsconfig.json
βββ README.md
---
| Feature | Status |
|--------|--------|
| Strapi v5 compatible | β
|
| AWS SDK v3 | β
|
| Cloudflare R2 region:auto | β
|
| Multi-bucket support | β
|
| Private/public logic | β
|
| Signed URLs | β
|
| Streaming upload | β
|
| Delete all formats | β
|
| Typescript | β
|
---
This package:
- Never exposes S3 credentials
- Does not trust user-supplied bucket names
- Sanitizes input paths
- Ensures private file access is signed-only
- Ensures deterministic bucket selection
This makes it safe for multi-tenant SaaS projects.
Strapiβs Admin Panel currently does not pass the original object path to the provider when replacing a file via the Media Library β Replace action.
As a result:
- The replaced file is correctly uploaded to R2
- It uses the correct bucket
- BUT it is always placed at the root of the bucket
- Image formats (thumbnail, small, etc.) also get placed at root
- Folder structure inside the Media Library remains unchanged
This is a Strapi core limitation β the Upload plugin does not provide the original fileβs path or folderPath` to the provider on replace.
No upload provider (AWS S3, DigitalOcean Spaces, or community R2 providers) can fix this on their own.
If you need stable per-entity folder structures, prefer deleting and re-uploading files until Strapi exposes proper replace-path hooks.
A GitHub issue will be linked here once opened.
---
MIT β free for commercial and open-source usage.
---
PRs, issues, and suggestions are welcome.
Feel free to open discussions for feature improvements.