Hosting static sites with Cloudflare R2 and MinIO Client

Brett Weir โ€ข Jun 19, 2023 โ€ข 11 min read

There are countless services nowadays for hosting static sites: GitHub, GitLab, Netlify, Surge, Porkbun, DigitalOcean, even Cloudflare.

If there are so many ways to get a static site online, why would anyone bother with setting up a plain ol' S3 bucket?

Well, there are lots of reasons:

  • Hosting multiple versions of a site. If you want v1, v2, and v3 at the same time, but don't want to commit your built sites to Git, then you need a writable location.

  • Hosting many sites from one domain. Maybe you're a hosting service! Or maybe you just provide hosting for multiple users in your company. You can build out a consistent workflow on top of S3 and host all the content in a single location.

  • Total control of how the sites are published. Maybe you want to vary available content by region, add authentication, use server-side analytics, or just configure how content is cached by the CDN. Building your own custom workflow will give you access to all the levers you need.

In this article, we'll develop a recipe for using Cloudflare R2 as a static site hosting service. You will:

  • Create a simple static site,

  • Publish the site to Cloudflare R2 with MinIO Client, and

  • Use Cloudflare Transform Rules to make your bucket behave more like a web server.

By the end of it, you will be the proud owner of a many-headed static site hydra that you'd never know was a simple S3 bucket underneath.


The easiest way to meet all the prerequisites for this tutorial is to complete the previous tutorial in this series, Serve static assets with Cloudflare R2.

Here's a summary of the things you'll need:

  • A domain name proxied by Cloudflare.

  • A Cloudflare account.

  • An R2 bucket configured for public access.

Step 0: Build a static site (optional)

If you came here because you already have a static site that you're ready to publish, use that and skip this section. For everyone else, you can set up an example site with me.

I'll be using MkDocs, because it's fast and simple and generates some nice boilerplate so that the site isn't completely empty. MkDocs is written in Python, so you'll need Python installed (which probably isn't an issue if you're on Linux).

Install the mkdocs package:

pip install mkdocs

This installs the mkdocs command. You can test that mkdocs is available by doing the following:

mkdocs --version
$ mkdocs --version
mkdocs, version 1.4.2 from /home/brett/.pyenv/versions/3.10.7/lib/python3.10/site-packages/mkdocs (Python 3.10)

Create a new mkdocs.yml project in the current directory:

mkdocs new .
$ mkdocs new .
INFO     -  Writing config file: ./mkdocs.yml
INFO     -  Writing initial docs: ./docs/

One more thing: let's add a subpage for this site. You'll see why this is important later in the article:

cat > docs/ <<EOF
# About

Some more info about that.

You can run a local dev server to see your changes in action:

mkdocs serve
$ mkdocs serve
INFO     -  Building documentation...
INFO     -  Cleaning site directory
INFO     -  Documentation built in 0.05 seconds
INFO     -  [23:22:45] Watching paths for changes: 'docs', 'mkdocs.yml'
INFO     -  [23:22:45] Serving on

Visit your local dev server at Our fancy new docs site isn't going to be anything to write home about, but it'll do the job.

New MkDocs static site served locally.
New MkDocs static site served locally.

When you're satisfied with your site, you can build a finished site for hosting like so:

mkdocs build
$ mkdocs build
INFO     -  Cleaning site directory
INFO     -  Building documentation to directory: /home/brett/Projects/examples/mkdocs-site/site
INFO     -  Documentation built in 0.05 seconds

This will create a site/ directory that contains our finished site, which is what we'll publish to Cloudflare R2.

Step 1: Deploy the site with MinIO Client

MinIO Client is far and away the best S3 command line tool I've found.

It's written in Go, so getting it onto your system is easy, and it supports a ton of commands that alternatives such as aws s3 or s3cmd simply don't have.

First, download and install the mc tool:

curl -O
sudo install mc /usr/local/bin/
$ curl -O
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 24.9M  100 24.9M    0     0  10.2M      0  0:00:02  0:00:02 --:--:-- 10.2M
$ sudo install mc /usr/local/bin/

The mc command should be usable at this point:

mc --version
$ mc --version
mc version RELEASE.2023-06-15T15-08-26Z (commit-id=bf3924b58341eb7a71785653a29bf26ca9fac95e)
Runtime: go1.19.10 linux/amd64
Copyright (c) 2015-2023 MinIO, Inc.
License GNU AGPLv3 <>

mc allows you to configure a connection by creating an alias. You can have as many aliases configured as desired.

Use the mc alias set command to configure your R2 connection:



  • NAME is the desired name for your alias. You'll have to type this often, so it's better to keep it short. I'll call mine r2.

  • XXXXXX is your Cloudflare account ID.

  • YYYYYY is your Cloudflare R2 access key ID.

  • ZZZZZZ is your Cloudflare R2 secret access key.

Once you've configured an alias, you can test it out or access it by prefixing the desired path with the alias:

mc ls r2/
$ mc ls r2/
[2023-06-17 18:38:11 UTC]     0B sites/

Hey, that's the sites bucket! Let's try accessing it!

$ mc ls r2/sites/

The above prints nothing. That's good! There's nothing in the bucket!

At this point, we've verified that the bucket works. Now we can put some stuff in it.

For hosting a static site, far and away the best tool for the job is the mc mirror command, which synchronizes files between two locations:


In our case, we'll set it up to synchronize the local MkDocs site/ directory to the R2 bucket. We'll add the --overwrite flag so that it overwrites existing files if there are any differences, and we'll add the --remove flag so that it deletes files from the target that no longer exist in the source.

This will be great for when we create a pipeline to continuously publish changes to a site.

mc mirror site r2/sites/latest/ --overwrite --remove
$ mc ls r2/sites/
$ mc mirror site r2/sites/latest/ --overwrite --remove 1.38 MiB / 1.38 MiB โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” 556.07 KiB/s 2s

If we browse to the published location, we'll be able to access the individual files we just uploaded:

Root `index.html` of the published MkDocs site.
Root index.html of the published MkDocs site.

We're not done yet though.

Step 2: Rewrite trailing slashes

You may have noticed that if you try to click on the About link, you get an error:

Clicking on the **About** link leads to an error.
Clicking on the About link leads to an error.

Web servers these days will almost always rewrite a URL that ends in a trailing slash (/) to an index.html file at the same path. In other words:

Is the same page as:

This allows you to visit and the contents of, which is how it worked when we tested our site locally. Cloudflare doesn't do this by default, which is why our About link leads to nowhere.

We can tell Cloudflare to behave like this when serving our R2 site, using Rewrite URL Rules. That way, our links will work, and we'll be able to access our site at

Go to the Cloudflare dashboard for your domain, and click Rules, then Transform Rules in the sidebar:

Navigate to the **Transform Rules** page.
Navigate to the Transform Rules page.

Click the Create rule button under the Rewrite URL tab:

Click the **Create rule** button.
Click the Create rule button.

Add an actually good name for your rule. It's the only way you'll be able to remember what it does without reading through your rule expressions:

Add a good name for your rule.
Add a good name for your rule.

Under If..., select Custom filter expression, and add the following expressions to the Expression Builder with an And between them:

URI Pathends with/
Configure the expression to match requests for the Rewrite URL rule.
Configure the expression to match requests for the Rewrite URL rule.

Alternatively, you can edit the expression manually by clicking Edit expression and add the following:

( eq "" and ends_with(http.request.uri.path, "/"))

Under Then..., then under Path, select the Rewrite to... option, select Dynamic, and add the following expression (see screenshot image below for reference):

concat(http.request.uri.path, "index.html")

This uses the concat function to append index.html to the URLs of matched request.

And under Query, select Preserve.

Rewrite the path, but preserve the query string.
Rewrite the path, but preserve the query string.

When you're ready, click Deploy. Then your new rule will be live:

The new Rewrite URL rule is live.
The new Rewrite URL rule is live.

At this point, you should be able to navigate to your site's URLs and see that they're accessible without adding index.html to the path:

[``]( is accessible. is accessible.
Following the About link takes us to the About page, no problem.
Following the About link takes us to the About page, no problem.

Congratulations, we've built a fully functional static site hosting service!


Cloudflare R2 is deeply integrated with Cloudflare and easy to get started with. MinIO Client makes working with S3 clean and obvious. Together, they provide a slim, bare-bones hosting solution that is highly adaptable to different needs and use cases.

What's better, all of the steps taken in this article are easy to automate in a CI pipeline, allowing you to build a general solution for your team or company that scales with your users.

For simple use cases, I'd still rather use an off-the-shelf solution, but this is one of those tools that you keep in your toolbox, because you never know when you're going to need it. Sometimes, all you really need is something you can hack on and make your own.


#cloudflare #minio #r2 #s3 #site #static