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
- In the Cloudflare dashboard, navigate to R2 Object Storage > Create bucket.
- Bucket Name: Use a descriptive name such as
my-org-ci-cache. - Location Hint: Choose a region hint close to your GitHub Actions runners:
wnam(Western North America) orenam(Eastern North America) for default GitHub-hosted runners.weuroreeurfor European runners.apacfor 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.devtest 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:
- In your bucket page, click Settings > Lifecycle Rules > Add rule.
- Set a rule name (e.g.,
expire-old-caches). - Set Action to Delete objects.
- Set Age to 30 days (or 60 days for low-frequency branches).
- 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
- In the Cloudflare dashboard, navigate to R2 > Manage R2 API Tokens > Create API token.
- Token Name:
github-actions-cache. - Permissions: Select Object Read & Write.
- Bucket Scope: Select Apply to specific buckets only, and choose your cache bucket.
- (Optional) Set a TTL / Expiration date to satisfy organizational security rotation policies.
- 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 Name | Source in Cloudflare |
|---|---|
R2_ACCOUNT_ID | Found on the R2 overview page in the right sidebar |
R2_ACCESS_KEY_ID | Displayed on the token creation screen |
R2_SECRET_ACCESS_KEY | Displayed on the token creation screen |
Workflow Configuration
- 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: ~/.npmTIP
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)
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"