Skip to content

Cloudflare R2 Setup

Cloudflare R2 provides S3-compatible object storage with zero egress fees, making it one of the most cost-effective and highest-throughput caching backends for GitHub Actions workflows.


Best Practices: Bucket Setup & Configuration

Follow these guidelines when setting up your Cloudflare R2 cache bucket:

1. Bucket Creation & Location Hint

  1. In the Cloudflare dashboard, navigate to R2 Object Storage > Create bucket.
  2. Bucket Name: Use a descriptive name such as my-org-ci-cache.
  3. Location Hint: Choose a region hint close to your GitHub Actions runners:
    • wnam (Western North America) or enam (Eastern North America) for default GitHub-hosted runners.
    • weur or eeur for European runners.
    • apac for Asia-Pacific runners.
    • Or leave as Automatic for dynamic edge placement.

2. Security & Public Access

  • Keep Public Access Disabled: Do not enable the public R2 r2.dev test domain or attach a custom domain. Cache archives contain build artifacts and private dependencies and must remain completely private.
  • Data Encryption: All data stored in Cloudflare R2 is automatically encrypted at rest using strong AES-256 ciphers with zero manual configuration needed.

3. Lifecycle Expiration Rules

To prevent outdated cache archives from accumulating indefinitely:

  1. In your bucket page, click Settings > Lifecycle Rules > Add rule.
  2. Set a rule name (e.g., expire-old-caches).
  3. Set Action to Delete objects.
  4. Set Age to 30 days (or 60 days for low-frequency branches).
  5. Add an Abort incomplete multipart uploads rule after 7 days.

Credentials & API Token Setup

Never use account-wide administrative tokens for CI workflows. Create a scoped, bucket-specific API token:

1. Create a Scoped R2 API Token

  1. In the Cloudflare dashboard, navigate to R2 > Manage R2 API Tokens > Create API token.
  2. Token Name: github-actions-cache.
  3. Permissions: Select Object Read & Write.
  4. Bucket Scope: Select Apply to specific buckets only, and choose your cache bucket.
  5. (Optional) Set a TTL / Expiration date to satisfy organizational security rotation policies.
  6. Click Create API Token.

2. Configure GitHub Secrets

Cloudflare will display the credentials once. Copy and store them in your repository's Settings > Secrets and variables > Actions:

Secret NameSource in Cloudflare
R2_ACCOUNT_IDFound on the R2 overview page in the right sidebar
R2_ACCESS_KEY_IDDisplayed on the token creation screen
R2_SECRET_ACCESS_KEYDisplayed on the token creation screen

Workflow Configuration

yaml
- name: Cache dependencies using Cloudflare R2
  uses: xSAVIKx/cloud-cache-action@v1
  with:
    bucket: my-org-ci-cache
    endpoint: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
    access-key: ${{ secrets.R2_ACCESS_KEY_ID }}
    secret-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-
    path: ~/.npm

TIP

Automatic Provider Detection: Cloud Cache Action automatically recognizes *.r2.cloudflarestorage.com endpoints and configures region: auto and force-path-style: false automatically.


Live CI Verification Workflow

This action is tested continuously against Cloudflare R2 with zero egress fees. You can inspect the live GitHub Actions workflow file in the repository: .github/workflows/provider-r2.yml.

.github/workflows/provider-r2.yml (Click to view full workflow)
yaml
name: Provider - Cloudflare R2

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
  schedule:
    - cron: '0 4 * * *'

jobs:
  test-cloudflare-r2:
    name: Cloudflare R2 Live Integration
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Setup Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 24

      - name: Check Cloudflare R2 credentials
        id: check-secrets
        run: |
          if [ -z "${{ secrets.R2_ACCOUNT_ID }}" ] || [ -z "${{ secrets.R2_ACCESS_KEY_ID }}" ] || [ -z "${{ secrets.R2_SECRET_ACCESS_KEY }}" ] || [ -z "${{ secrets.R2_BUCKET }}" ]; then
            echo "has_secrets=false" >> $GITHUB_OUTPUT
            echo "::notice::Cloudflare R2 secrets (R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET) are not configured. Skipping live Cloudflare R2 integration test."
          else
            echo "has_secrets=true" >> $GITHUB_OUTPUT
          fi

      - name: Generate synthetic test payload
        if: steps.check-secrets.outputs.has_secrets == 'true'
        run: |
          mkdir -p /tmp/r2-cache-test
          echo "R2 test artifact $(date +%s%N) for run ${{ github.run_id }}" > /tmp/r2-cache-test/sample.txt
          sha256sum /tmp/r2-cache-test/sample.txt > /tmp/r2-expected-sha.txt
          cat /tmp/r2-expected-sha.txt

      - name: Save cache to Cloudflare R2 (save-only)
        if: steps.check-secrets.outputs.has_secrets == 'true'
        uses: ./save
        with:
          path: /tmp/r2-cache-test
          key: r2-live-test-${{ github.run_id }}-${{ github.run_attempt }}
          bucket: ${{ secrets.R2_BUCKET }}
          endpoint: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
          access-key: ${{ secrets.R2_ACCESS_KEY_ID }}
          secret-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}

      - name: Purge local test payload
        if: steps.check-secrets.outputs.has_secrets == 'true'
        run: rm -rf /tmp/r2-cache-test

      - name: Restore cache from Cloudflare R2 (restore-only)
        if: steps.check-secrets.outputs.has_secrets == 'true'
        id: restore-r2
        uses: ./restore
        with:
          path: /tmp/r2-cache-test
          key: r2-live-test-${{ github.run_id }}-${{ github.run_attempt }}
          bucket: ${{ secrets.R2_BUCKET }}
          endpoint: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
          access-key: ${{ secrets.R2_ACCESS_KEY_ID }}
          secret-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          fail-on-cache-miss: true

      - name: Verify restored content and checksum
        if: steps.check-secrets.outputs.has_secrets == 'true'
        run: |
          echo "Asserting cache hit..."
          if [ "${{ steps.restore-r2.outputs.cache-hit }}" != "true" ]; then
            echo "::error::Expected cache-hit to be 'true', got '${{ steps.restore-r2.outputs.cache-hit }}'"
            exit 1
          fi

          if [ ! -f /tmp/r2-cache-test/sample.txt ]; then
            echo "::error::Restored file /tmp/r2-cache-test/sample.txt not found!"
            exit 1
          fi

          ACTUAL_SHA=$(sha256sum /tmp/r2-cache-test/sample.txt | awk '{print $1}')
          EXPECTED_SHA=$(awk '{print $1}' /tmp/r2-expected-sha.txt)
          if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
            echo "::error::Checksum mismatch! Expected $EXPECTED_SHA, got $ACTUAL_SHA"
            exit 1
          fi

          echo "Live Cloudflare R2 cache save & restore test succeeded! Checksum: $ACTUAL_SHA"