Migrate a media library off Cloudinary — or any HTTP‑reachable CDN — onto Cloudflare R2, then repoint every reference in your database. Built for the real version of this job: the assets aren't in a folder, they're URLs scattered across dozens of tables and columns, embedded in HTML, JSON blobs and CSV.
R2 has no egress fees and flat, cheap storage, so this migration typically takes a metered image CDN bill from hundreds of dollars a month to under a dollar.
scan → find every source URL in the DB, write a manifest
migrate → download each asset once, upload to R2 with a content-hashed key
rewrite → swap the URLs in the database (dry-run first, then --commit)
verify → HEAD-check every migrated URL
rollback → undo the last rewrite from its backup file
- Idempotent. Object keys are
‹prefix›/‹aa›/‹sha256›‹ext›. Re-running never uploads the same bytes twice; a half-finished run resumes where it stopped. - Resumable. Progress is flushed to the manifest continuously. Kill it, fix a network issue, run it again.
- Dry-run by default.
rewriteshows you exactly which cells would change and how. Nothing is written until you pass--commit. - Reversible. Every committed rewrite is recorded to a
*.rewrite-backup.json;rollbackputs the old URLs back inside a transaction, and only where the cell hasn't changed since. - Read-mostly.
scan,migrateandverifynever touch your database for writes. Onlyrewrite --commitandrollbackdo, and both run in a transaction. - No SQL injection surface. Table, column and key identifiers are
validated against
^[A-Za-z_][A-Za-z0-9_.$]*$and dialect-quoted; every value is bound as a parameter.
npm i -g cloudinary-to-r2
# plus the driver for your database:
npm i -g pg # Postgres
npm i -g mysql2 # MySQL / MariaDBOr run without installing:
npx cloudinary-to-r2 scanRequires Node 20+.
Create cloudinary-to-r2.config.json (see
cloudinary-to-r2.config.example.json):
{
"database": { "client": "postgres", "url": "env:DATABASE_URL" },
"source": {
"match": ["res.cloudinary.com/your-cloud-name", "https://cdn.old-host.example.com"]
},
"r2": {
"accountId": "env:R2_ACCOUNT_ID",
"bucket": "media",
"accessKeyId": "env:R2_ACCESS_KEY_ID",
"secretAccessKey": "env:R2_SECRET_ACCESS_KEY",
"publicBaseUrl": "https://media.example.com",
"keyPrefix": "media"
},
"scan": {
"include": [
{ "table": "products", "pk": "id", "columns": ["image_url", "gallery_json", "description"] },
{ "table": "posts", "pk": "id", "columns": ["body_html"] }
]
},
"concurrency": 8
}Any string value may be env:VAR_NAME to read it from the environment (a local
.env file is picked up automatically). See .env.example.
Getting R2 credentials: Cloudflare dashboard → R2 → Manage R2 API Tokens →
create a token with Object Read & Write on the target bucket. The accountId
is in the dashboard URL and on the R2 overview page. publicBaseUrl is your
bucket's custom domain (recommended) or its *.r2.dev URL.
- List the tables and columns you know hold media in
scan.include. Each entry needs a single-columnpk. - Or set
"autoDiscover": trueto additionally scan every text/JSON column on every table that has a single-column primary key. Slower, but it catches the column you forgot. The two can be combined.
cloudinary-to-r2 scan # → media.manifest.json
cloudinary-to-r2 migrate # download + upload; safe to re-run
cloudinary-to-r2 rewrite # DRY RUN — prints the diff
cloudinary-to-r2 rewrite --commit # writes, inside a transaction
cloudinary-to-r2 verify # HEAD-check every new URLIf some assets fail to download (dead source URLs are common in old libraries), fix what you can and:
cloudinary-to-r2 migrate --retry-failed
cloudinary-to-r2 rewrite --commit --allow-partial # rewrite only what migratedMade a mistake?
cloudinary-to-r2 rollbackscan writes ‹bucket›.manifest.json — the single source of truth for the
migration. It records each unique asset, its upload status, the content hash,
the resulting R2 key and URL, and every (table, row, column) that references
it. It's plain JSON: diff it, grep it, hand-edit an asset's status to
skipped to exclude it. Keep it until the migration is fully verified; it's
git-ignored by default.
| Flag | Applies to | Meaning |
|---|---|---|
-c, --config <path> |
all | Config file. Default ./cloudinary-to-r2.config.json |
-m, --manifest <path> |
all | Manifest file. Default ./‹bucket›.manifest.json |
--retry-failed |
migrate |
Also retry assets that failed a previous run |
--commit |
rewrite |
Actually write. Without it, rewrite is a dry run |
--allow-partial |
rewrite |
Proceed even if some assets are still unmigrated |
-v, --verbose |
all | Per-item logging |
- It doesn't delete anything from Cloudinary. Cut that over yourself once you've verified R2 is serving everything.
- It doesn't transform images. R2 stores the original bytes; put Cloudflare Images or a resizing worker in front if you need on-the-fly variants.
- It doesn't touch files on disk (e.g. a WordPress
wp-content/uploadsfolder). It works on URLs stored in a SQL database.
npm install
npm run dev -- scan # run from source with tsx
npm test # vitest
npm run typecheck
npm run build # → dist/MIT © Abdullah Al Akhzar