Cloudflare R2 S3 Storage Provider - Multi Bucket
A production-ready Cloudflare R2 provider for Strapi v5 offering multi-bucket support, secure private file access, signed URLs, and full format deletion.
strapi-provider-cloudflare-r2-advanced
Advanced Cloudflare R2 provider for Strapi v5
✨ Multi-bucket support · 🔐 Private signed URLs · 🚀 AWS SDK v3 · 🪶 TypeScript
🚀 Overview
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
- Advanced 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!
📦 Installation
npm install strapi-provider-cloudflare-r2-advanced
# or
yarn add strapi-provider-cloudflare-r2-advanced⚙️ Configuration (Strapi v5)
Create or modify:
1/config/plugins.tsExample configuration with multi-bucket setup
1export default () => ({
2 upload: {
3 config: {
4 provider: "strapi-provider-cloudflare-r2-advanced",
5 providerOptions: {
6 endpoint: env("CF_ENDPOINT"), // Example: "https://<accountid>.r2.cloudflarestorage.com"
7
8 // Optional internal prefix for all stored R2 object keys
9 // If rootPath = "v1/uploads", your files will be stored like:
10 // v1/uploads/company/123/file.jpg
11 rootPath: null,
12
13 // Optional override for the returned PUBLIC URLs (applies only to buckets listed in publicDomains)
14 // If baseUrl = "https://cdn.example.com/assets", final URLs become:
15 // https://cdn.example.com/assets/company/123/file.jpg
16 baseUrl: null,
17
18 /**
19 * Cloudflare R2 Credentials
20 * Obtain these at:
21 * https://dash.cloudflare.com/[your-account-id]/r2/api-tokens
22 */
23 accessKeyId: env("R2_ACCESS_KEY_ID"),
24 secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
25
26 /**
27 * Bucket routing by *logical* name.
28 *
29 * IMPORTANT:
30 * These names are NOT special — "public" / "private" are NOT reserved.
31 * You can choose ANY bucket name, e.g. "uploads", "invoices", "tenantAssets".
32 *
33 * The *privacy* of a bucket depends ONLY on whether it has a corresponding entry
34 * inside `publicDomains`.
35 */
36 buckets: {
37 uploads: env("CF_BUCKETS_UPLOADS"), // logicalName: actualBucketName
38 internalAssets: env("CF_BUCKETS_INTERNAL_ASSETS")
39 },
40
41 /**
42 * Public CDN domains
43 *
44 * A bucket becomes PUBLIC if (and only if) it appears in this object.
45 * If a bucket key does NOT exist here -> it becomes PRIVATE and uses SIGNED URLs.
46 *
47 * TIP:
48 * Use environment variables prefixed with CF_PUBLIC_ACCESS_URL_*
49 * (Important to correctly generate security middleware)
50 */
51 publicDomains: {
52 uploads: env("CF_PUBLIC_ACCESS_URL_UPLOADS") // Only 'uploads' bucket is public
53 },
54
55 // Default bucket if none is matched via prefix or file path
56 defaultBucket: "uploads",
57
58 // Signed URL TTL (applies only to private buckets)
59 signedUrlExpires: 3600
60 }
61 }
62 }
63});Frontend Upload Example (Vanilla /api/upload)
A minimal example of uploading from your frontend (Nuxt/Vue, React, plain JS, etc.):
1// Example: Nuxt/Vue Composition API
2const file = ref<File | null>(null);
3
4async function upload() {
5 const formData = new FormData();
6
7 // The important part: include your desired path
8 // This determines bucket + folder routing:
9 // Example: bucket:public:company/123/logos
10 formData.append("path", "bucket:public:company/123/logos");
11
12 // The actual file (or multiple)
13 formData.append("files", file.value as File);
14
15 const res = await fetch("/api/upload", {
16 method: "POST",
17 body: formData,
18 });
19
20 const uploaded = await res.json();
21 console.log("Uploaded:", uploaded);
22}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
Middleware Configuration (CSP for Public Domains)
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:
1export default ({ env }) => {
2 const prefix = 'CF_PUBLIC_ACCESS_URL_';
3
4 // Extract domain hostnames from env vars:
5 const domains = Object.keys(process.env)
6 .filter(key => key.startsWith(prefix))
7 .map(key => process.env[key])
8 .filter(Boolean)
9 .map((domain: string) => domain.replace(/^https?:\/\//, ""));
10
11 return [
12 'strapi::logger',
13 'strapi::errors',
14 {
15 name: "strapi::security",
16 config: {
17 contentSecurityPolicy: {
18 useDefaults: true,
19 directives: {
20 "connect-src": ["'self'", "https:"],
21 "img-src": [
22 "'self'",
23 "data:",
24 "blob:",
25 "market-assets.strapi.io",
26 ...domains
27 ],
28 "media-src": [
29 "'self'",
30 "data:",
31 "blob:",
32 "market-assets.strapi.io",
33 ...domains
34 ],
35 upgradeInsecureRequests: null,
36 },
37 },
38 },
39 },
40 // ... rest of middleware stack
41 ];
42};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)
🔌 Upload Behavior
✔ Multi-bucket routing
Bucket selection is based on:
bucket:prefix found in file.pathproviderOptions.bucketsdefaultBucket
Example path:
1bucket:private:company/123/invoicesThis file will always use the private bucket.
✔ Public vs. Private URL generation
Public bucket example:
1https://cdn.example.com/company/123/file.jpgPrivate bucket example:
Uses signed URLs generated via AWS SDK v3:
1https://<r2-endpoint>/company/.../file.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256 ...🔐 Signed URLs (Private)
You can manually request a signed URL using:
1const url = await strapi
2 .plugin("upload")
3 .provider.getSignedUrl(file);Private files always return signed URLs.
Public files never return signed URLs.
🗑️ Full File Deletion (Including Formats)
Strapi often generates image formats:
thumbnailsmallmediumlarge
This provider deletes all formats, not just the main file.
Use Strapi’s own service:
1await strapi
2 .plugin("upload")
3 .service("upload")
4 .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.
📘 How Provider Metadata is Stored
On each file Strapi stores:
1{
2 "bucket": "private",
3 "key": "company/abc123/file.jpg",
4 "isPrivate": true
5}Formats include their own metadata as well.
🧪 Testing
Install dependencies:
npm install
npm test(Tests are scaffold-ready; add more for your use-case.)
🏗 Project Structure
1strapi-provider-cloudflare-r2-advanced/
2├── src/
3│ └── index.ts # Provider implementation
4├── dist/ # Compiled output
5├── tests/ # Basic test suite
6├── package.json
7├── tsconfig.json
8└── README.md💡 Features Summary
| 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 | ✅ |
🔐 Security & Stability
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.
⚠️ Known Limitation: Replace Operation Inside Strapi Media Library
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.
📜 License
MIT — free for commercial and open-source usage.
🙌 Contributing
PRs, issues, and suggestions are welcome.
Feel free to open discussions for feature improvements.
Install now
npm install strapi-provider-cloudflare-r2-advanced
Create your own plugin
Check out the available plugin resources that will help you to develop your plugin or provider and get it listed on the marketplace.