I wanted to stop paying for build minutes I kept exceeding every month from frequent deploys. Between the idea and the site actually live on Cloudflare Workers there were node_modules published by mistake, redirects breaking because of a hidden default, an SDK that simply doesn't run in that environment, and a nameserver cutover to pull off without losing the site or the mailbox.
This is the full list of what tripped me up migrating a real, live site — not a toy project — from Netlify to Cloudflare Workers static assets + serverless functions.
Why leave the previous host: it wasn't bandwidth, it was deploy frequency
The site had been hosted on the same platform for years, and the free tier had been enough. The problem showed up with the publishing pace: with at least 10 deploys a week, the limit that ran out first was never bandwidth, it was monthly build minutes — every deploy burns a fixed number of minutes regardless of how small the site is, and that count adds up fast. The result was a paid plan for a site that, traffic-wise, didn't actually need one.
Cloudflare Workers instead caps the number of builds per month, not accumulated minutes — at the same publishing pace, the headroom is dramatically wider.
The first deploy published the npm dependencies too
The first deploy attempt looked clean on the surface — no errors, a working preview URL. Looking more closely at the log, though, one line stood out: among the thousands of files uploaded as static assets were entire packages from node_modules, including server-side libraries with authentication logic.
The reason is simple: without a file explicitly declaring what to exclude from the asset deploy, the tool uploads everything it finds in the project folder. The fix is an .assetsignore file in the repository root — separate from the source code's .gitignore — listing the folders to keep out of the public site:
node_modules
.git
.github
netlify.toml
package.json
package-lock.json
wrangler.jsonc
After the fix, the published file count dropped from thousands to a few hundred — exactly the site's real content.
A hidden default breaks every extension-based redirect
The site has always used canonical URLs with a .html extension for some sections and without one for others, with a redirect file acting as the map. After converting that map to the equivalent Cloudflare format, almost every page stopped responding — not just the ones involved in redirects, but pages that had never been touched.
The cause was a behavior active by default and never explicitly declared: without explicit configuration, Cloudflare applies automatic URL normalization that strips the .html extension and adds or removes the trailing slash, directly conflicting with any hand-written redirect that wants different behavior. The fix is disabling it explicitly in the Worker's wrangler.jsonc:
{
"assets": {
"directory": "./",
"html_handling": "none",
"not_found_handling": "404-page"
}
}
The default that caused the most damage. Turning off automatic URL normalization also disables the implicit "folder → index.html" behavior, including for the site root: every folder with its own index file needs an explicit entry in the redirect file, not just the pages that already had a hand-written rule on the old platform.
Why testing an endpoint from the browser can be misleading
With redirects finally sorted, one of the serverless function endpoints kept returning 404 — but only when the URL was typed directly into the address bar. The same function, called from the site's own code via a normal fetch() request, worked fine.
The explanation: a request typed into the browser gets internally flagged as "navigation", and to save billable invocations Cloudflare can redirect it straight to an error page without even running the Worker's code. The fix is run_worker_first, forcing the Worker to run for a specific path pattern regardless of request type:
{
"assets": {
"directory": "./",
"html_handling": "none",
"run_worker_first": ["/.netlify/functions/*"]
}
}
Essential for any endpoint you want to be able to test by hand.
The environment variables that disappeared on every deploy
Moving on to functions that needed credentials, a surprising behavior showed up: environment variables added through the dashboard worked on the first test, then vanished after the next deploy.
The cause is that Wrangler treats the project's configuration file as the single source of truth for variables — any change made by hand through the dashboard, if it's not also in the configuration file, gets overwritten on the next run. The fix is keep_vars: true:
{
"keep_vars": true
}
After that, dashboard-set secrets stay stable no matter how many deploys follow.
Firebase's Admin SDK simply doesn't start
The deepest problem came with the functions that read and write Firestore. All of them used Firebase's official Admin SDK for Node.js — and under the hood, that SDK uses gRPC to talk to Firestore. gRPC requires direct TCP connections, and the serverless function environment on Cloudflare isn't real Node.js, it's a V8 isolate (the same engine Chrome uses, sandboxed): it doesn't support gRPC, not even with the compatibility flags meant for other Node.js APIs. There's no flag or setting that fixes this — the Admin SDK simply cannot run in that environment.
The fix was writing a small module that talks to Firestore over REST, bypassing the SDK entirely:
- Sign a JWT with the service account's credentials using the Workers environment's native Web Crypto API.
- Exchange it for an OAuth2 access token from Google.
- Use that token to call Firestore's REST endpoints directly (reads, writes, filtered queries).
The token is cached in memory for as long as the instance stays warm, so the full handshake doesn't have to be redone on every single request. Once this module was written and tested against one simple function, every other function touching Firestore built on the same code, without redoing the trickiest part from scratch.
The domain cutover: a low TTL in advance, and not everything gets "proxied"
The last step, and the one with the most real consequences if handled badly, was moving the actual domain onto the new infrastructure. The lowest-risk sequence:
- Lower the TTL on the relevant DNS records days before the cutover (from a typical one hour down to a few minutes).
- Wait for the old value to expire out of caches worldwide.
- Flip the nameservers.
During propagation — anywhere from minutes to a few hours — some visitors still see the old infrastructure and others see the new one, but there's never a moment where the domain simply doesn't respond.
One easy detail to get wrong: on the new platform, only the records that actually serve web traffic (the root domain and its "www" alias) should be routed through Cloudflare's network. Everything else — mail servers, service subdomains, any FTP records — should stay "DNS only", pointed directly at the original infrastructure without going through the proxy. A mail client or FTP client can't talk to a Worker: if those records end up behind the proxy by mistake, email stops working the exact moment the website starts working — definitely not the trade you want.
What I take away from this
The thread running through the whole migration is that the two environments look interchangeable until they aren't: same concept of a redirect, same concept of a serverless function, same concept of an environment variable — but with different defaults in non-obvious places, and in at least one case (Firebase's Admin SDK) with an architectural constraint no configuration can work around.
None of these problems were predictable from reading the starting documentation: they surfaced by testing page by page, endpoint by endpoint, fixing one default at a time. The end result justifies the effort, though: same site, same functions, zero downtime during the cutover, and a monthly hosting cost that went from a small fixed fee down to zero.
This originally appeared on roversia.it, where I write about Firebase, Cloudflare, PWAs, and shipping real side projects. If you're interested in another piece of server-side infrastructure, I also wrote about how I secured an internal panel with Firebase Custom Tokens and deny-by-default RTDB rules instead of a shared hardcoded password.
Top comments (0)