Thinking Out Loud

Escaping the Hotlink Trap: Automating an Image Migration to Cloudflare R2

Anthony Shead/June 19, 2026/3 min read
Escaping the Hotlink Trap: Automating an Image Migration to Cloudflare R2
fig. 1 - Escaping the Hotlink Trap: Automating an Image Migration to Cloudflare R2

Recently, I noticed a glaring issue on my blog: the cover images on my postcards were suddenly throwing 404 errors. After doing some digging into my infrastructure, I realized I had fallen into a classic developer trap, taking a shortcut that came back to bite me. Here's a breakdown of how I diagnosed the broken images, built an automated script to migrate my assets to Cloudflare R2, and squashed a fun TypeScript bug along the way.

The Diagnosis: Checking the R2 Bucket When the "Error 404 Object not found" messages started appearing, my first instinct was to check my Cloudflare R2 configuration. I verified that my bucket (blog-images) had valid credentials and that public access was enabled on my custom R2 domain (pub-196fa866c9204ea18c2dc7ae564f3bad.r2.dev). My environment variables (CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, etc.) were all perfectly set in .env.local and Vercel.

The real issue? The bucket was essentially empty, save for a single test file. When I published my first 5 posts, I completely bypassed my own app/api/upload/route.ts endpoint. Instead of using the "Browse" button I built in the admin editor, I simply pasted external Pinterest URLs directly into the cover image text field. Pinterest actively blocks hotlinking, so those external URLs were rejecting my site's requests.

The Solution: An Idempotent Migration Script To fix this permanently, I wrote an automated migration script (scripts/migrate-images-to-r2.ts). I designed the script to handle the entire pipeline programmatically: it downloads each Pinterest image, re-uploads it directly to my R2 bucket, and updates the MongoDB record with the new permanent R2 URL.

One of the most important architectural decisions here was making the script idempotent. This means the script checks the database first and automatically skips any posts that are already pointing to R2. Because of this, I can safely run the script multiple times without worrying about duplicate uploads or overwriting valid data.

(Code snippet placeholder: the part of the script that checks whether the URL already includes the R2 domain before proceeding.) Squashing a TypeScript and MongoDB Bug (Commit 289e145)

During the migration process, I ran into a great learning moment regarding TypeScript and MongoDB query operators. To find the posts that needed migrating, I had to query the database for records where the cover image URL was neither null nor an empty string. My initial query looked like this:

[
// The broken query const postsToMigrate = await db.collection('posts').find({ coverImage: { $ne: null, $ne: "" } }).toArray();

]

TypeScript immediately (and correctly) rejected this because I was using duplicate $ne keys within the same object literal. The fix was to refactor the query using the $nin (not in) operator instead:

[ // The working query const postsToMigrate = await db.collection('posts').find({ coverImage: { $nin: [null, ""] } }).toArray();

]

New posts, in your inbox

Roughly one a week. Confirm once, unsubscribe anytime.

Writing like this is reader-supported.Support on Patreon →

0 comments

Read next