
By Fanvyr
A production-ready Cloudflare R2 provider for Strapi v5 offering multi-bucket support, secure private file access, signed URLs, and full format deletion.
released February 2, 2026
npm install strapi-provider-cloudflare-r2-advanced
β¨ Multi-bucket support Β· π Private signed URLs Β· π AWS SDK v3 Β· πͺΆ TypeScript
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:
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!
npm install strapi-provider-cloudflare-r2-advanced
# or
yarn add strapi-provider-cloudflare-r2-advancedCreate or modify:
/config/plugins.tsexport default () => ({
upload: {
config: {
provider: "strapi-provider-cloudflare-r2-advanced",
providerOptions: {
endpoint: env("CF_ENDPOINT"), // Example: "https://<accountid>.r2.cloudflarestorage.com"
// 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
}
}
}
});/api/upload)A minimal example of uploading from your frontend (Nuxt/Vue, React, plain JS, etc.):
// Example: Nuxt/Vue Composition API
const file = ref<File | null>(null);
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:
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:
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:
bucket: prefix found in file.pathproviderOptions.bucketsdefaultBucketExample path:
bucket:private:company/123/invoicesThis file will always use the private bucket.
Public bucket example:
https://cdn.example.com/company/123/file.jpgPrivate bucket example:
Uses signed URLs generated via AWS SDK v3:
https://<r2-endpoint>/company/.../file.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256 ...You can manually request a signed URL using:
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:
thumbnailsmallmediumlargeThis provider deletes all formats, not just the main file.
Use Strapiβs own service:
await strapi
.plugin("upload")
.service("upload")
.remove(file);This:
You should NOT call provider.delete() directly.
On each file Strapi stores:
{
"bucket": "private",
"key": "company/abc123/file.jpg",
"isPrivate": true
}Formats include their own metadata as well.
Install dependencies:
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:
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:
thumbnail, small, etc.) also get placed at rootThis 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.
Share your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.
Submit
