Cloudflare R2 Workers

Configuring a Cloudflare R2 Bucket and Worker for Public Access

I recently published a plugin to integrate Cloudflare R2 with Craft CMS. After having a chance to utilize this on a few more projects after its release, I wanted to put together a guide to streamline this process for future use cases. Hopefully this will help out any others out there looking to setup R2 on their Cloudflare accounts, as most of this won't be specific to Craft CMS.

Cloudflare announced their "Rapid and Reliable" R2 storage service last year, and even though it's still in beta I've been looking forward to working with it based on their cost savings over Amazon S3 which I use extensively at my primary job. To this end, I published a Cloudflare R2 plugin for Craft CMS to be able to use this more readily and easily in all projects.

To get setup, Cloudflare does require you to "purchase" an R2 plan (Free tier will work fine) and have a credit card on file in order to proceed. But once that's set up, you're good to go. When that's complete, head back (if not there already) using the R2 (Beta) link in the main navigation for your account.

Creating a bucket

Click the 'Create bucket' button to get started creating the bucket and give it a name. This will be restricted to 3-63 characters and may only contain lowercase letters, numbers, and hyphens. By default, objects will NOT be publicly accessible, but we'll set up a Worker in the next section to fix this.

Once that's created, you'll be back at your list of R2 buckets. In the right-hand sidebar you'll see your Account ID which you'll need in the next step, as well as a link to Manage R2 API Tokens. Click this link, and then click the 'Create API token' button in the top-right corner. Here you can modify the token name, change Permissions to "Edit" unless you only need GET/LIST permissions, add edit the TTL and IP Address filtering as necessary. Click the 'Create API token' button again and copy the resulting Access Key and Secret Access Key somewhere safe. Cloudflare provides a guide to Generate an S3 Auth Token as well.

As far as the plugin for Craft is concerned, this is all you need to get it setup in your Craft install. Add your Account ID, Access Key, and Secret Access Key (and optionally your bucket name) to your .env file. Head to Settings -> Filesystems in your Craft install and click the New Filesystem button, referencing your environment variables and updating the Subfolder and Cache Duration settings as needed. Click Save and head to Settings -> Assets to create your new volume(s) as usual.

Enabling public access

This applies not only to those using the Craft plugin, but to any current R2 bucket as Cloudflare has public access disabled by default although they are actively working on it (keep in mind it is still in beta). But public access can be enabled through Cloudflare Workers.

Back in your Cloudflare account, head to the Workers link in the sidebar just above the R2 (Beta) link that was just used. If you haven't already, you'll first need to set up a Workers subdomain for your account. Essentially, every single worker you create will be accessed at [WORKER_NAME].[SUBDOMAIN].workers.dev, so choose this wisely. Although this subdomain can always be changed, you would also need to update any and all references to it at the same time.

With your subdomain created, you're ready to create your first Worker. Back on the Workers overview page, click the Create a Service button. Give your service (worker) a name, which like the bucket may only contain lowercase letters, numbers, and hyphens, although the last character cannot be a hyphen. The Select a starter section allows you to kick off your service with some initial code to hit the ground running, but you'll soon be able to modify or replace this to meet your needs so change this as needed and click Create Service to get started.

Cloudflare also provides an R2 Get Started Guide so follow this to Install Wrangler (Step 1), Authenticate Wrangler (Step 2), and Bind Your Bucket to a Worker (Step 4). You are also able to Create your R2 Bucket in Step 3, but we already created this through the Cloudflare account directly.

This is where it gets interesting, because the core of the Worker is what drives the result when any resource in your bucket is requested through the Worker's URL. To use Cloudflare's example, let's say you have an image named 'cat-pic.jpg' in your bucket. Instead of requesting this through the bucket's URL (ACCOUNT_ID.r2.cloudfla...), this will be requested through the Worker's URL at WORKER_NAME.SUBDOMAIN.workers.dev. To modify what happens when this request, you can use the Quick Edit button from the Worker's Resources page or through the index.js file if working on this locally (per Cloudflare's R2 Get Started Guide).

For my own purposes, I essentially want to limit all requests to particular directories. This way I can restrict all objects that should be accessed publicly to those directories, but still use any other directories to store my own objects that should still be restricted from public access.

To do that, I start with the default export that Cloudflare uses while keeping track of the URL and Key in the request:

export default {
   async fetch(request, env) {
       const url = new URL(request.url);
       const key = url.pathname.slice(1);
   },
};

We don't care about anything other than GET requests, so we can immediately respond with 403 Access Denied otherwise:

export default {
   async fetch(request, env) {
       const url = new URL(request.url);
       const key = url.pathname.slice(1);

       // Return 403 if anything other than GET request
       if (request.method !== 'GET') {
           return new Response('Access Denied', {
               status: 403
           });
       }
   }
};

Then we can take a look at the beginning of that requested Key to see the path of the object being requested. In this case, if it's not in the "email/" folder (which I use for all email signature-related files) or the "uploads/" folder (where I keep all uploads that should be publicly accessible) then we'll also respond with 403 Access Denied:

export default {
   async fetch(request, env) {
       const url = new URL(request.url);
       const key = url.pathname.slice(1);

       // Return 403 if anything other than GET request
       if (request.method !== 'GET') {
           return new Response('Access Denied', {
               status: 403
           });
       }

       // Return 403 if anything other than email/ and uploads/ folders
       if(key.indexOf('email/') !== 0 && key.indexOf('uploads/') !== 0) {
           return new Response('Access Denied', {
                status: 403
           });
       }
   }
};

Next, we'll get the object being requested and make sure that it actually exists, otherwise respond with 404 Object Not Found. I've found that the request may include encoded characters as well, which is why we're running decodeURI() on the key for this portion:

export default {
    async fetch(request, env) {
        const url = new URL(request.url);
        const key = url.pathname.slice(1);

        // Return 403 if anything other than GET request
        if (request.method !== 'GET') {
            return new Response('Access Denied', {
                status: 403
            });
        }

        // Return 403 if anything other than email/ and uploads/ folders
        if(key.indexOf('email/') !== 0 && key.indexOf('uploads/') !== 0) {
            return new Response('Access Denied', {
                 status: 403
            });
        }

        // Get object
        const object = await env.BUCKET.get(decodeURI(key));

        // Return 404 if object not found
        if (object === null) {
            return new Response('Object Not Found', {
                status: 404
            });
        }
    }
};

Finally, we'll make sure to include the object's etag value in the response headers and return those along with the object itself:

export default {
    async fetch(request, env) {
        const url = new URL(request.url);
        const key = url.pathname.slice(1);

        // Return 403 if anything other than GET request
        if (request.method !== 'GET') {
            return new Response('Access Denied', {
                status: 403
            });
        }

        // Return 403 if anything other than email/ and uploads/ folders
        if(key.indexOf('email/') !== 0 && key.indexOf('uploads/') !== 0) {
            return new Response('Access Denied', {
                 status: 403
            });
        }

        // Get object
        const object = await env.BUCKET.get(decodeURI(key));

        // Return 404 if object not found
        if (object === null) {
            return new Response('Object Not Found', {
                status: 404
            });
        }

        // Include headers
        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set('etag', object.httpEtag);

        // Return object
        return new Response(object.body, {
            headers,
        });
    }
};

And that's it! This can be modified as necessary to restrict to any other particular directories, and even restrict access to specific IP address or ranges and more.

Using a custom domain

One last optional step that, to me, is the cherry on top is customizing the URL that's used to access your Worker. By default, this will be WORKER_NAME.SUBDOMAIN.workers.dev, but if you're managing your site's DNS through Cloudflare which I imagine is the case then you'll be able to quickly and easily set up a custom route to set this to a more user-friendly URL.

Navigate back to your Worker and select the Triggers tab. In the first section, click the Add Custom Domain button and add any custom domain that works with zones currently in your account.

Now you can use your custom domain and Cloudflare will automatically route requests to your Worker.

Something not working right for you, or would you like to see a particular example? Leave a comment below and I'll do my best to update this post to help you out.

You May Also Like

Securing a Site With a Cloudflare Client Certificate and mTLS
Cloudflare Security

Securing a Site With a Cloudflare Client Certificate and mTLS

When a website required limited access, I needed a way to lock it down to specific physical devices. I couldn't rely on IP addresses which might change regularly, and while a strong password requirement might be sufficient I wanted something a little more secure. Not to mention that it shouldn't be crawled by any search engines either. The solution was a Cloudflare client certificate and mTLS firewall rule.

Learn More