Strapi plugin logo for Cloudflare R2 S3 Storage Provider - Multi Bucket

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.

thumbnail for Cloudflare R2 S3 Storage Provider - Multi Bucket

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.ts

Example 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:

  1. bucket: prefix found in file.path
  2. providerOptions.buckets
  3. defaultBucket

Example path:

1bucket:private:company/123/invoices

This file will always use the private bucket.


✔ Public vs. Private URL generation

Public bucket example:

1https://cdn.example.com/company/123/file.jpg

Private 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:

  • thumbnail
  • small
  • medium
  • large

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

FeatureStatus
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

STATS

No GitHub star yetNot downloaded this week

Last updated

26 days ago

Strapi Version

Unavailable

Author

github profile image for fanvyr
fanvyr

Related plugin

Documentation

Useful links

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.