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.

Use Object Read only for jobs that never save. See the Security guide for the token types and the S3 calls the action sends.

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]
    types: [opened, synchronize, reopened, labeled]
  workflow_dispatch:
  schedule:
    - cron: '0 4 * * *'

jobs:
  test-cloudflare-r2:
    name: Cloudflare R2 Live Integration
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Setup Node.js
        uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.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 ~/r2-cache-test
          echo "R2 test artifact $(date +%s%N) for run ${{ github.run_id }}" > ~/r2-cache-test/sample.txt
          sha256sum ~/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: ~/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 ~/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: ~/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 ~/r2-cache-test/sample.txt ]; then
            echo "::error::Restored file ~/r2-cache-test/sample.txt not found!"
            exit 1
          fi

          ACTUAL_SHA=$(sha256sum ~/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"

      - name: Look up by restore-keys
        if: steps.check-secrets.outputs.has_secrets == 'true'
        id: restore-keys-r2
        uses: ./restore
        with:
          path: ~/r2-cache-test
          key: r2-live-test-${{ github.run_id }}-${{ github.run_attempt }}-missing
          restore-keys: r2-live-test-${{ github.run_id }}-
          lookup-only: true
          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: Verify the restore-keys match
        if: steps.check-secrets.outputs.has_secrets == 'true'
        run: |
          test "${{ steps.restore-keys-r2.outputs.cache-hit }}" = "false"
          test "${{ steps.restore-keys-r2.outputs.cache-matched-key }}" = "r2-live-test-${{ github.run_id }}-${{ github.run_attempt }}"

Notes ​

  • Checksums: for this provider, the action sends request checksums only when S3 requires them, because many S3-compatible services reject the CRC checksums recent AWS SDKs send by default. Nothing to configure.
  • Expiry: the action never deletes caches. Add a lifecycle rule that expires objects after 30–60 days so old caches do not accumulate.