Skip to main content

Deploying Static Website on Cloudflare R2

·7 mins

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:

  1. Build and deploy on every push to the main branch
  2. 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 Read
  • Workers R2 Storage Write
  • Zone Write
  • Zone Settings Read
  • Zone Settings Write
  • DNS Read
  • DNS Write
  • Zone Transform Rules Read
  • Zone Transform Rules Write

Prepare required infrastructure #

For infrastructure I would need this set of resources:

  1. Cloudflare R2 bucket
  2. S3 Compatible bucket credentials
  3. 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 Read
  • Workers 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
  • dev will start a local Hugo server
  • build will build a static website for a given base URL
  • deploy will 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.