Deploying Static Website on Cloudflare R2
Table of Contents
This website archives my personal notes and serves as my CV. This article explains how I built and deployed it. While using production-grade deployment for a personal website might be over-engineering, it’s a great way to showcase professional-quality work.
I use Hugo for my personal website—a great static website generator. Hugo’s build output is static content, which makes deploying to a storage bucket a logical choice. While I’m familiar with Google Cloud, AWS, and Azure, I wanted to try something new. Cloudflare seemed like a great solution.
My solution includes:
- Build and deploy on every push to the
mainbranch - Manage infrastructure using OpenTofu in Spacelift
Prerequisites #
To follow this guide, you’ll need:
- A Cloudflare account
- A registered domain (I use Cloudflare’s domain services)
- Static content to deploy (in this example I’ve been using Hugo)
- rclone (for syncing build artifacts with R2)
Optional but recommended tools:
- OpenTofu CLI (for Cloudflare infrastructure management)
- CI/CD tool of your choice (I am going to use GitHub Actions).
Optional: Cloudflare API Account Token #
To use OpenTofu for managing cloud resources, grant the following permission groups to Cloudflare API Token.
Workers R2 Storage ReadWorkers R2 Storage WriteZone WriteZone Settings ReadZone Settings WriteDNS ReadDNS WriteZone Transform Rules ReadZone Transform Rules Write
Prepare required infrastructure #
For infrastructure I would need this set of resources:
- Cloudflare R2 bucket
- S3 Compatible bucket credentials
- Transform rules
Create R2 Bucket #
While you can create a bucket through the Cloudflare dashboard, using infrastructure as code provides better maintainability. Here’s the OpenTofu configuration to create a bucket:
locals {
cloudflare_account_id = "<change-me-cloudflare-account-id>"
}
resource "random_uuid" "bucket" {}
resource "cloudflare_r2_bucket" "site" {
account_id = local.cloudflare_account_id
name = random_uuid.bucket.result
location = "weur"
storage_class = "Standard"
}
Note on bucket naming: This configuration uses a random UUID for the bucket name. R2 bucket names must be unique within your Cloudflare account. Using a UUID ensures no conflicts if you destroy and recreate the infrastructure.
Assigning R2 Bucket a custom domain #
I have barskov.dev domain that I want to assign to my personal blog. Since
both domain and the bucket are managed by Cloudflare, it’s quite easy to
make it happen.
First, import your Cloudflare Zone to OpenTofu. You can find your Zone ID in the Cloudflare dashboard.
import {
to = cloudflare_zone.site
id = "<change-me-zone-id>"
}
resource "cloudflare_zone" "site" {
account = {
id = local.cloudflare_account_id
}
name = "<change-me-domain-name>"
type = "full"
}
Then assign the custom domain to R2 bucket
resource "cloudflare_r2_custom_domain" "site" {
account_id = local.cloudflare_account_id
bucket_name = cloudflare_r2_bucket.site.name
domain = cloudflare_zone.site.name
enabled = true
zone_id = cloudflare_zone.site.id
min_tls = "1.3"
}
Configuring CORS #
CORS configuration is required when using R2 with custom domains for public access. The CORS policy allows browsers to load resources from your custom domain. Configure it to allow GET requests from your domain:
resource "cloudflare_r2_bucket_cors" "site" {
account_id = local.cloudflare_account_id
bucket_name = cloudflare_r2_bucket.site.name
rules = [{
allowed = {
methods = [
"GET",
]
origins = [
"https://${cloudflare_zone.site.name}",
]
headers = []
}
id = "Allow access to the bucket from site domain"
expose_headers = ["Content-Encoding", ]
max_age_seconds = 3600
}]
}
Configure transform rules #
Serving a Hugo website from an R2 bucket requires configuring transform rules
for trailing slashes. These rules ensure that requests to directories (like
/about/) and paths without trailing slashes (like /about) both resolve to
the correct index.html files:
resource "cloudflare_ruleset" "barskov_dev" {
zone_id = cloudflare_zone.barskov_dev.id
kind = "zone"
name = "default"
phase = "http_request_transform"
rules = [
{
action = "rewrite"
action_parameters = {
uri = {
path = {
expression = <<-EOT
concat(http.request.uri.path, "index.html")
EOT
}
}
}
description = "Add index.html to paths with trailing slash"
expression = <<-EOT
(http.host eq "${cloudflare_zone.barskov_dev.name}" and ends_with(http.request.uri.path, "/"))
EOT
},
{
action = "rewrite"
action_parameters = {
uri = {
path = {
expression = <<-EOT
concat(http.request.uri.path, "/index.html")
EOT
}
}
}
description = "Add /index.html to paths without trailing slash"
expression = <<-EOT
(http.host eq "${cloudflare_zone.barskov_dev.name}" and not ends_with(http.request.uri.path, "/") and not http.request.uri.path contains ".")
EOT
}
]
}
Configure Cloudflare API Token for deployment #
To automatically deploy your website from your CI/CD workflow, you need another Cloudflare API Token with the following permission groups:
Workers R2 Storage Bucket Item ReadWorkers R2 Storage Bucket Item Write
These permission groups allow you to sync static assets to your R2 bucket. You can create the token with OpenTofu:
data "cloudflare_account_api_token_permission_groups_list" "r2" {
account_id = local.cloudflare_account_id
scope = urlencode("com.cloudflare.edge.r2.bucket")
}
resource "cloudflare_api_token" "github_actions" {
name = "github-actions-site"
policies = [{
effect = "allow"
permission_groups = [
{ id = local.cloudflare_account_api_token_permission_groups.r2["Workers R2 Storage Bucket Item Read"].id },
{ id = local.cloudflare_account_api_token_permission_groups.r2["Workers R2 Storage Bucket Item Write"].id },
]
resources = jsonencode({
"com.cloudflare.edge.r2.bucket.*" = "*"
})
}]
status = "active"
}
Provision these credentials to GitHub Actions secrets. Cloudflare API tokens require special handling when used as S3 credentials: the token ID serves as the access key, while the SHA256 hash of the token value serves as the secret key. This is due to how Cloudflare validates API tokens in S3-compatible mode (see this issue).
resource "github_actions_secret" "barskov_dev_s3_access_key_id" {
repository = "<change-me-repository-name>"
secret_name = "RCLONE_S3_ACCESS_KEY_ID"
plaintext_value = cloudflare_api_token.github_actions.id
}
resource "github_actions_secret" "barskov_dev_s3_secret_access_key" {
repository = "<change-me-repository-name>"
secret_name = "RCLONE_S3_SECRET_ACCESS_KEY"
plaintext_value = sha256(cloudflare_api_token.github_actions.value)
}
Note: You’ll need the GitHub Terraform provider configured to use these resources.
Configure CI/CD pipeline #
The CI/CD pipeline will:
- On pull requests: verify the static website builds successfully
- On pushes to main: deploy the website by syncing build content to the R2 bucket
I’ll use rclone for syncing.
Configuring rclone #
To use rclone, configure it for S3-compatible access to Cloudflare R2. Create an rclone.conf file with the storage type and provider:
[r2]
type = s3
provider = Cloudflare
The credentials RCLONE_S3_ACCESS_KEY_ID and RCLONE_S3_SECRET_ACCESS_KEY are provisioned from the GitHub Actions secrets created earlier.
The R2 S3-compatible endpoint follows this format:
https://<ACCOUNT_ID>.r2.cloudflarestorage.com
You can find your account ID in the Cloudflare dashboard URL when viewing R2 buckets (it appears in the URL path). Use this endpoint value in your GitHub Actions workflow as RCLONE_S3_ENDPOINT.
For detailed configuration instructions, refer to the official Cloudflare documentation.
Configure task runner #
To simplify routine tasks, I configured the following justfile (you can use any other task runner):
set dotenv-load
set unstable
default:
printenv
just --list
configure:
mise install --silent
hugo mod get
dev base_url="http://localhost:1313/":
hugo server --baseURL="{{ base_url }}" --buildDrafts --buildExpired --buildFuture --enableGitInfo --openBrowser
build base_url:
hugo build --minify --quiet --baseURL="{{ base_url}}" --enableGitInfo
deploy base_url bucket_name: (build base_url)
rclone sync public r2:{{ bucket_name }} \
--config rclone.conf \
--check-first \
--checksum \
--quiet
devwill start a local Hugo serverbuildwill build a static website for a given base URLdeploywill sync the content of the build folder to R2 bucket
Important note on rclone sync: The sync command will delete files in
R2 that don’t exist in your local public/ directory. This ensures the bucket
exactly mirrors your build output.
To preview changes before deploying:
rclone sync public r2:{{ bucket_name }} \
--config rclone.conf \
--check-first \
--checksum \
--dry-run \
--verbose
If you need to preserve files in R2 that aren’t in your build output, use
rclone copy instead of sync.
Configure CI/CD pipeline #
GitHub Actions CI/CD job for static website deployment should conceptually have the following content:
on:
workflow_call:
inputs:
working-directory:
description: "Working directory"
required: true
type: string
secrets:
s3_secret_access_key_id:
required: true
s3_secret_access_key:
required: true
jobs:
main:
runs-on: ubuntu-latest
container:
image: ghcr.io/nikitabarskov/base-ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
RCLONE_S3_ACCESS_KEY_ID: ${{ secrets.s3_secret_access_key_id }}
RCLONE_S3_SECRET_ACCESS_KEY: ${{ secrets.s3_secret_access_key }}
RCLONE_S3_ENDPOINT: https://a71133601c96b473ef65e3c9ea99d689.r2.cloudflarestorage.com
MISE_DATA_DIR: ~/.local/share/mise
HUGO_CACHE_DIR: ~/.cache/hugo_cache
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v6
- uses: actions/cache@v5
with:
path: |
${{ env.MISE_DATA_DIR }}
key: ${{ runner.os }}-mise-${{ hashFiles('mise.lock') }}
restore-keys: |
${{ runner.os }}-mise-
- uses: actions/cache@v5
with:
path: |
${{ env.HUGO_CACHE_DIR }}
key: ${{ runner.os }}-hugomod-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-hugomod-
- run: |
echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
git config --global --add safe.directory $(pwd)
just configure
- if: ${{ github.event_name != 'push' && github.ref != 'refs/heads/main' }}
run: |
just build https://barskov.dev
- if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
run: |
just deploy https://barskov.dev b887d6f9-0a56-58e7-b401-410545a90d39
This is a reusable workflow that can be found in my repository.
I use a custom container image with all required tools preinstalled. You can of course use GitHub Actions for installing hugo and rclone instead.
Conclusion #
While Cloudflare Pages is the recommended solution for static website hosting, R2 provides a viable alternative with more control over your deployment process. This approach offers flexibility and can be particularly useful for specific use cases or when you need direct storage management.