For about a year, my portfolio at rubarajan.dev was served by nginx on a Raspberry Pi sitting in my house, exposed to the internet through a Cloudflare Tunnel. Deploying meant running an scp script from my laptop.
It worked. That's the thing — it genuinely worked, for a year. But the site is 500 KB of static HTML, CSS, and fonts. No backend. No build step. No database. There was nothing about it that needed a computer in my living room.
So I moved it to Cloudflare Workers. The migration itself is genuinely simple — point a config file at a folder and push. What made it worth writing about is everything around it: a performance assumption that turned out to be half wrong, an image that had been silently broken for months, and a www subdomain that took an hour and three separate failure modes to get right.
The honest "why"
Let me start with the part most migration posts get wrong, because I got it wrong too.
My first instinct was: "the Pi gives me zero CDN benefit, every request travels to my house." I checked before believing myself:
curl -sI https://rubarajan.dev/ | grep -i cf-cache-status
cf-cache-status: DYNAMIC
Cache-Control: no-cache
Confirmed! Except that was only the HTML document. When I checked the actual assets:
| Path | cf-cache-status |
|---|---|
/ (HTML) |
DYNAMIC, no-cache
|
/styles.css |
REVALIDATED, max-age=14400
|
/assets/fonts/*.woff2 |
HIT, immutable, age 6 days |
My fonts and CSS were already edge-cached. Only the HTML document round-tripped to the Pi. My dramatic framing was wrong.
The real case was narrower but still solid:
- The HTML document is the critical path for first byte. Every visitor waited on my home internet before a single asset could even start loading.
- Availability was 100% Pi-dependent. Power cut, ISP blip, SD card wear, a clock drift bug that had already bitten me once — any of those and the document 502s. Cached fonts don't save a page that never loads.
Worth stating plainly: if you're going to migrate for performance reasons, measure first. You might find half your assumption is already false.
The cutover risk was zero, which surprised me
My domain was already on Cloudflare — that's how the tunnel worked. So both apex and www already resolved to Cloudflare anycast IPs:
$ nslookup example.com
Addresses: <Cloudflare anycast addresses>
Cloudflare was already the front door. Migrating only changed what happened behind their edge. The visitor-facing addresses never changed — no propagation window, no TTL waiting, no split-brain where some users hit the Pi and others hit the new setup.
If you're already proxying through Cloudflare, your migration is far lower-risk than it feels.
Two decisions before writing any config
Workers Static Assets, not Pages. Cloudflare Pages is still supported, but Cloudflare's own guidance is that new projects should use Workers — that's where the investment goes. Starting on Pages today means migrating later for no reason.
No GitHub Actions. I planned to write a workflow. I didn't need one. Cloudflare has native Git integration: connect the repo, and every push to main deploys. No workflow file, no API token in secrets, no CI minutes. For a site with no build step, Actions is a moving part that buys nothing. Add it the day you actually have a build.
The entire config is nine lines:
// wrangler.jsonc
{
"name": "my-site",
"compatibility_date": "2026-08-03",
"assets": {
"directory": "./public"
}
}
No main entry. A purely static site needs no Worker script at all.
Gotcha 1: everything in your assets directory is public
That "directory": "./public" matters more than it looks.
Workers serves every file in the assets directory. My repo root contained a deploy script with my Pi's SSH target in it. Had I pointed assets.directory at the repo root, that file would have been fetchable at example.com/deploy.ps1.
So: site files go in public/, everything else stays outside it.
public/ <- ONLY this is servable
index.html
styles.css
assets/
wrangler.jsonc <- not servable
.env <- not servable
deploy.ps1 <- not servable
And then verify it, rather than trusting the layout:
for f in deploy.ps1 .env wrangler.jsonc .gitignore; do
echo "$(curl -s -o /dev/null -w '%{http_code}' https://example.com/$f) $f"
done
404 deploy.ps1
404 .env
404 wrangler.jsonc
404 .gitignore
This is now a permanent check in my deploy notes. It takes four seconds and it's the difference between "I think it's fine" and "I know it's fine."
Gotcha 2: my hotlinked images had already broken
My site showed logos for four companies I've worked at, each hotlinked straight from the employer's own CDN. I'd never thought about it again.
for u in "$EXL_URL" "$BNY_URL" "$COGNIZANT_URL" "$TCS_URL"; do
curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" "$u"
done
200
200
200
403 <- TCS
One had been returning 403 — hotlink protection — for who knows how long. My live site had a broken image and I had no idea.
This is worth internalizing beyond my specific case: hotlinked assets are an availability dependency on someone else's infrastructure. I was migrating for reliability while four images sat hostage to four other companies' CDNs. Any of them can add hotlink protection on a random Tuesday.
Downloading them into assets/logos/ fixed the broken one, removed four third-party dependencies, and stopped leaking visitor IPs to those companies. Should have done it years ago.
Gotcha 3: an SVG without a viewBox won't scale
The TCS logo needed a new source. I found one on Wikimedia Commons, dropped it in, and then noticed this:
<svg
width="956.69287"
height="342.99213"
id="svg2">
width and height, but no viewBox.
That combination breaks CSS sizing. With style="height:30px" and no viewBox, the SVG's internal coordinate system stays fixed at 956×343 user units while the viewport shrinks to 30px tall — so you see the top-left corner of the drawing, clipped, rather than the whole logo scaled down.
The fix is one attribute:
viewBox="0 0 956.69287 342.99213"
Same numbers as the width and height. Now the content scales to fit whatever box CSS gives it.
I verified the framing was right by extracting every coordinate from the path data and checking the bounds:
layer transform : -161.07883 -76.646639
after transform : x[35.4, 921.3] y[35.4, 307.6]
viewBox : 0 0 956.7 343.0
content fits : YES
A symmetric 35.4-unit margin on all four sides — the viewBox was correct.
That padding had a second consequence: the mark only fills 79% of its box, so at height:30px it rendered noticeably smaller than the neighbouring logos. Bumping it to 38px (30 × 343/272) matched their visual weight.
Gotcha 4: www was a whole saga
The apex went live in one click. www fought me for an hour.
First: adding www as a Custom Domain failed with Hostname already has externally managed DNS records. Fair — the tunnel owned that record. I deleted it.
Then: No zones match www.example.com. Start by adding the domain to Cloudflare.
This one is a trap. The domain was already on Cloudflare — the apex Custom Domain had succeeded on the same Worker in the same zone minutes earlier. Following that "add the domain" prompt would have tried to onboard a duplicate zone. If you see this and you know your domain is already there, don't follow the prompt.
I gave up on the dialog and used a proxied CNAME plus a Redirect Rule instead. Which produced:
HTTP 522. Connection timed out.
The reason is worth understanding: a proxied CNAME www → example.com does not chain through to the Worker. www had no Worker binding and no real origin, so Cloudflare tried to fetch from an origin that didn't exist and timed out.
The Redirect Rule is what fixes it — redirect rules fire at the edge before Cloudflare attempts any origin fetch. The CNAME alone will always 522. You need both.
Then the rule matched only the homepage. Every other path still 522'd. The cause was the match field:
❌ URI Full wildcard "www.example.com"
✅ Hostname equals "www.example.com"
URI Full is the entire URI — https://www.example.com/styles.css. A bare hostname pattern never matches it. Matching on Hostname covers every path on that host.
Final working configuration:
Match: http.host eq "www.example.com"
Action: Dynamic redirect
Value: concat("https://example.com", http.request.uri.path)
Status: 301, preserve query string
One last trap: right after saving, results were mixed — some paths 301, others still 522. That's propagation across edge nodes, not a broken rule. Test twice, a minute apart, before concluding anything is wrong. I nearly rewrote a rule that was already correct.
Where it landed
| Before | After | |
|---|---|---|
| HTML document |
DYNAMIC, round-trip to my house |
HIT at the edge |
| Deploy |
scp from my laptop |
git push |
| Rollback | re-run scp and hope | one click, every deploy retained |
| Availability | one Pi, one ISP, one SD card | Cloudflare's anycast network |
| Broken TCS logo | yes | fixed |
| Cost | electricity | $0 |
After every change I re-ran the same check rather than trusting that it had worked:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
curl -sI https://example.com/ | grep -i cf-cache-status
curl -sL https://example.com/ | wc -c
Status, cache state, and byte count — three seconds, and it catches the difference between "deployed" and "actually serving what I think it's serving."
Takeaways
- Measure before you migrate. Half my performance justification was already false.
- If you're already behind Cloudflare, cutover risk is near zero — the edge IPs never change.
- Everything in your assets directory is public. Put site files in a subdirectory and verify with curl that the rest 404s.
- Hotlinked images are an availability dependency. One of mine had already broken silently.
-
An SVG with
width/heightbut noviewBoxwill not scale — it clips. -
wwwis a separate hostname, not a synonym. It needs its own configuration and it will not fall back to your apex.
The migration itself was maybe ten minutes of actual work. The rest was discovering what had quietly rotted while the site sat there working — a dead image, an unscalable SVG, a performance story I'd told myself without checking. That part was more useful than the move.
The site is rubarajan.dev, now served entirely from Cloudflare's edge and deployed with git push.
Top comments (0)