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.
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 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]
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.