I run a travel directory: about 800 prerendered pages, 190 redirect rules built up over a year of renames, and a small database behind it. For its whole life the front end lived on Vercel's free Hobby plan. Two things ended that this week.
The first was an email saying I'd used 75% of my deployment storage. The second was something I'd been putting off: Hobby is for non-commercial use, and this site earns affiliate commission, so it never really belonged there. Paying $20 a month wasn't justified yet. Cloudflare Workers has a free tier that serves static files without charging for them, and the worker itself gets 100,000 requests a day. The site was averaging about 16,000.
I expected a settings job. Point the build at a new host, copy a config file across, change two DNS records. Claude Code did the hands-on work on my home server, I approved each step, and the rule was the same as my database move a few days earlier: build it alongside the live site, prove it's the same, switch once, keep a way back.
There was nothing to copy
The vercel.json in that repo is over 650 lines of redirects, headers and rewrites. It looks like settings. You'd scroll past it the way you scroll past a .prettierrc.
Cloudflare doesn't read it. Its own config has a handful of options and no rule list like that one. Everything in that file would have to be done by something else, and the only thing available was a small worker sitting in front of the static files, written from scratch.
That was the moment the job changed shape, because writing the worker meant answering questions the file never asks. In what order do the rules run? Do redirects come before static files? What does :slug+ match? Is matching case-sensitive? When a path has no file, what happens, and with what status code?
None of those answers are in vercel.json. They were in Vercel.
A config file isn't settings. It's a program, and the platform was its interpreter.
I had never written that interpreter, so I had never had to know what it did. The file looked simple because the complicated half lived on someone else's servers, and leaving meant bringing that half home.
Writing the interpreter
The worker came out at about 120 lines. It imports the original vercel.json directly, so that file stays the single source of truth, and it does what Vercel did in the same order:
- Redirects, first match wins, query string kept.
- A same-origin proxy path through to the database API.
- Static files.
- Any other path without a file extension gets the app shell with a 200.
- Headers from the file, added to every response.
The heart of it turns path patterns into regular expressions:
// "/guides/:city" or "/:path+/" → RegExp with named groups
function compile(source) {
let re = "";
for (let i = 0; i < source.length; ) {
const m = source.slice(i).match(/^:([A-Za-z_]\w*)(\([^)]*\))?([+*?])?/);
if (m) {
const [whole, name, custom, mod] = m;
const seg = custom ? custom.slice(1, -1) : "[^/]+?";
if (mod === "+") re += `(?<${name}>${seg}(?:/${seg})*)`;
else re += `(?<${name}>${seg})`;
i += whole.length;
continue;
}
re += source[i].replace(/[.^$|?*+[\]{}\\]/g, "\\$&");
i += 1;
}
return new RegExp(`^${re}$`);
}
That's simplified. The real one also handles *, ? and unnamed groups, and every one of those branches exists because a rule in the file needed it.
The behaviours were the spec
There's no document that says exactly what the old host does, so the old host had to be the document. I kept the Vercel copy reachable on its own URL and compared the two, response by response, across every URL in the sitemap and 202 redirect cases.
One of the first things it caught was a loop. My first version matched paths case-insensitively, because that seemed friendlier. The file has a rule that sends one mixed-case URL to its lowercase form, and with case-insensitive matching the lowercase URL matched the same rule and redirected to itself, forever. Vercel matches case-sensitively. Nothing in the file says so. The rule only works because of a behaviour the file silently depends on.
The rest were quieter:
- Cloudflare's static file handling strips
/index.htmland.htmlwith a 307, a temporary redirect. Every other redirect on the site is a permanent 308. Search engines treat those differently, so the worker now converts them. - Vercel adds
; charset=utf-8to text responses. Cloudflare doesn't. Most browsers would cope, but a page's declared encoding isn't something I want to leave to chance across 800 pages. -
robots.txt,sitemap.xmlandfavicon.icomust stay out of the app-shell fallback, or a missing one returns a cheerful 200 full of HTML.
None of these would have shown up in a browser. Each one would have sat there quietly doing small damage to a site whose traffic comes from search.
After the fixes, all 610 sitemap pages matched on status, title, canonical, robots tag and main heading. Every redirect matched except one deliberate change: /x/index.html now answers with a 308, which is better than the original. Response headers matched. Then a real headless browser loaded a page that fetches from the database, through the proxy, and the listings rendered.
The switch took four lines
The DNS was already on Cloudflare, so the cutover was a script that deleted two records and attached the two hostnames to the worker. All four lines came back True. The old records went to a backup file first, and the rollback is the same script in reverse. Vercel keeps the old project with its builds turned off, so going back would take minutes.
The part that failed was the boring part. Automatic builds died at the asset upload with error 10013, because the build token Cloudflare had picked was left over from a different site and had no rights on this one. Creating a fresh token fixed it. After all that careful comparison, the only outage risk that actually fired was a stale credential on the deploy path, and it never touched the live site, because a failed build just leaves the last good version in place.
The number I got wrong
One more thing, because it's the same mistake in a different costume.
When the storage email arrived, I read the dashboard's 8.81 GB as a measure of what was stored, deleted 90 old deployments, and set a one-week retention. The figure didn't move. So I decided it was a rolling count of everything built in the last 30 days, worked out about 56 MB per build across 158 builds, and wrote a monitor on that basis, stating confidently that deleting deployments doesn't lower it.
A day later the dashboard read about 230 MB. So that explanation was wrong too, or at least incomplete, and I still can't say for certain which change did it. I had taken a platform's number, invented a mechanism that fit it, and built tooling on the guess. A usage figure on a dashboard is the output of a program as well, and I didn't have the source for that one either.
What I'd keep from this
Anything a platform does for you without being asked is code you're depending on and haven't read. That's a fine trade, and it's most of why platforms are worth using. Just don't mistake it for simplicity. The bill arrives on the day you leave, as a list of behaviours you have to rediscover by measurement, because the only full specification was the running system.
So before you leave anything, keep the old one reachable and let it be your spec. Compare outputs, not intentions. And when something looks like plain settings, ask what's reading it.
Top comments (0)