A Bash script that maintains a mirror of remote files in a Cloudflare R2 bucket for CDN distribution. Files are only re-uploaded when their content has changed (detected by SHA-256 comparison), making it safe to run frequently or on a cron schedule.
Find a file
2026-05-14 20:44:19 +08:00
.gitignore Create .gitignore 2026-05-03 16:33:24 +08:00
AGENTS.md Add shell script development guidelines and initial project structure 2026-05-03 13:33:54 +08:00
cdn-mirror Add multi-URL fallback support for mirrored files 2026-05-14 20:40:49 +08:00
CLAUDE.md Add shell script development guidelines and initial project structure 2026-05-03 13:33:54 +08:00
LICENSE Add shell script development guidelines and initial project structure 2026-05-03 13:33:54 +08:00
README.md Add multi-URL fallback support for mirrored files 2026-05-14 20:40:49 +08:00

cdn-mirror

A Bash script that maintains a mirror of remote files in a Cloudflare R2 bucket for CDN distribution. Files are only re-uploaded when their content has changed (detected by SHA-256 comparison), making it safe to run frequently or on a cron schedule.

Requirements

Tool Purpose
bash ≥ 3.2 Shell runtime
curl Downloading source files
jq Parsing the JSON config
aws CLI v2 Uploading to R2 via the S3-compatible API
sha256sum Content-change detection (part of GNU coreutils)

Installing dependencies

# Debian / Ubuntu
sudo apt-get install curl jq coreutils

# AWS CLI v2 — see https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html

Installation

sudo curl -fsSL https://code.paulg.it/paulgit/cdn-mirror/raw/branch/main/cdn-mirror -o /usr/local/bin/cdn-mirror && sudo chmod +x /usr/local/bin/cdn-mirror

Configuration

The script reads a JSON configuration file. The default location is:

~/.config/cdn-mirror/config.json

If the file does not exist, the script will offer to create a template. The config file should be readable only by its owner:

chmod 600 ~/.config/cdn-mirror/config.json

Config file format

{
  "r2": {
    "account_id":        "YOUR_CLOUDFLARE_ACCOUNT_ID",
    "access_key_id":     "YOUR_R2_ACCESS_KEY_ID",
    "secret_access_key": "YOUR_R2_SECRET_ACCESS_KEY",
    "bucket":            "YOUR_BUCKET_NAME",
    "region":            "auto"
  },
  "files": [
    {
      "urls": [
        "https://cdn1.example.com/path/to/file.js",
        "https://cdn2.example.com/path/to/file.js"
      ],
      "destination": "js/file.js"
    },
    {
      "url":         "https://example.com/path/to/style.css",
      "destination": "css/style.css"
    }
  ]
}
Field Required Description
r2.account_id Yes Your Cloudflare account ID
r2.access_key_id Yes R2 API token access key ID
r2.secret_access_key Yes R2 API token secret access key
r2.bucket Yes R2 bucket name
r2.region No R2 region (default: "auto")
files[].urls Yes* Array of source URLs to try in order (fallback)
files[].url Yes* Single source URL (backward-compatible alternative)
files[].destination Yes Object key in the R2 bucket

* Either urls or url must be provided. If urls is present, the script tries each URL in order until one succeeds, retrying each up to three times before moving to the next.

R2 API tokens can be created in the Cloudflare dashboard under R2 → Manage R2 API Tokens. The token needs Object Read & Write permission on the target bucket.

Usage

Usage: cdn-mirror [OPTIONS]

Options:
  -c, --config <path>  Path to JSON config file.
                       Default: ~/.config/cdn-mirror/config.json
      --cronmode       Suppress all output except errors.
  -v, --version        Show version number and exit.
  -h, --help           Show this help message and exit.

Normal run (verbose)

./cdn-mirror

Custom config path

./cdn-mirror --config /path/to/my-config.json

Cron mode

Suppresses all output except errors, making it suitable for unattended execution. The script exits with code 1 if any file failed to download or upload.

./cdn-mirror --cronmode

Example crontab entry (run every hour)

0 * * * * /usr/local/bin/cdn-mirror --cronmode

How it works

  1. For each file listed in the config, the script downloads the source URL to a temporary file. If multiple URLs are configured, it tries each in order until one succeeds (retrying each up to three times).
  2. It computes the SHA-256 hash of the downloaded content.
  3. The hash is compared against the value recorded from the previous run, stored in ~/.local/share/cdn-mirror/hashes.json.
  4. If the hash has changed (or no previous run exists), the file is uploaded to R2 and the new hash is saved.
  5. If the hash matches, the upload is skipped.

Exit codes

Code Meaning
0 All files processed successfully
1 One or more files failed to download or upload

License

DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE — see LICENSE.