I Left Vercel for a VPS, and the Bill Was Never Vercel's Fault
Day one on Vercel Pro cost $9.02 with fifteen real users, and the cause was a missing function rather than a pricing model. Seven weeks later the on-demand line was $208 a month, so I moved to a $7.92 box. It charged me in a currency I had less of.
First day on Vercel Pro: $9.02. Next day: $1.69. PostHog said I had about fifteen real users that day. Cloudflare said the site served between 17,000 and 51,000 requests.
That gap is the whole story of the first half of this post, and the fifteen users are not the surprising part.
The bill was not a pricing problem
The line items were Image Optimization at $3.38, Cache Writes at $1.43, Fast Origin Transfer at $1.71, and Fluid Active CPU at $1.20. My first read was that Vercel is expensive at scale. That read was wrong, and it took a while to admit it, because "the platform is expensive" is a much more comfortable conclusion than the real one.
The real one: my Incremental Static Regeneration had never worked. Not once, not since launch.
In Next 16, a [param] route only becomes ISR if a generateStaticParams function exists.
It can return an empty array. It just has to be there. export const revalidate on its own
does nothing, and export const dynamicParams = true does even less than nothing, because it
is already the default and reads like a switch you have turned on.
I had both of those on every catalogue route and no generateStaticParams anywhere. So all
200,000-odd pages, artists, albums, tracks, genres, years, charts, ran full server-side
rendering on every single request, and answered with Cache-Control: private, no-store, which
meant neither Vercel nor Cloudflare could cache any of it. Every crawler hit paid for CPU, an
invocation, memory and origin transfer. Then the next crawler hit paid again.
The fix was thirty-four copies of this:
export function generateStaticParams() {
return [];
}
Nothing is built at build time, every slug renders on first request and is served from cache after that.
The lesson I actually keep from this one is about verification rather than caching. Config
exports are not evidence. The build table is: ● means static or ISR, ƒ means it runs on
every request. In production, curl -sI <url> | grep -i x-vercel-cache. I had shipped a first
attempt with dynamicParams = true, tested it with a shell variable that was silently empty,
accidentally checked the homepage, and concluded it worked.
Then the cost moved instead of leaving
Image optimization went from $4.81 to $0.13. The bill did not go down. It relocated.
Turning SSR into ISR turned every crawler visit into an ISR Write, and ISR Writes became about
70% of the invoice: $2.08 one day, $3.62 the next, the only line climbing. Because
revalidate was 3600, a page a bot visited hourly could be rewritten twenty-four times a day.
Two fixes. revalidate to 86400, so an actively crawled page is written once a day rather than
twenty-four times. Then the one that actually mattered: a Cloudflare Worker that intercepts
anonymous GETs, strips the Vary header, and serves them from caches.default. Bots never
reach Vercel's ISR at all.
That last one had to be a Worker, and finding out why cost a day. Next stamps
Vary: rsc, next-router-state-tree on App Router HTML, and a proxy.ts header override gets
overwritten on an ISR cache hit, because Next re-applies its own headers downstream. Cloudflare
will not cache a response with that Vary (it reports DYNAMIC), Transform Rules refuse to
touch the Vary header specifically, and Snippets are Pro and up. A Worker owns the response
outright, so it is the only layer with no seam for Next to reach through.
I lost most of that day to my own test command. The Worker had a if (method !== "GET") passthrough branch, and I verified everything with curl -sI, which sends HEAD. Every check
bypassed the GET path and showed me the raw origin response. The Worker had been working since
the first deploy. Real bot traffic is GET, so the behaviour was correct the whole time and only
my instrument was wrong.
A header test on method-dependent edge code is curl -s -o /dev/null -D - <url>, not
curl -I. And put your own proof header on it, so a hit is unambiguous.
The decision, once the panic was over
The fixes worked and the bill still climbed, because the catalogue kept growing and the crawlers kept finding it. By the end of July the Vercel on-demand line was $208 a month, on top of the $20 plan. ISR Writes alone were $110 of that, Edge Requests $37, Fast Origin Transfer $24.
So the question stopped being "is Vercel expensive" and became "is this workload a good fit for this pricing model". It is not, and that is a specific claim rather than a complaint. Two million ISR pages against per-write billing and metered egress is close to the worst possible match. On a box, those two line items are flat.
The replacement is a VPS at $7.92 a month. Plain SSD rather than NVMe, on purpose,
because it only renders on a Cloudflare miss and IOPS was never the binding constraint. I
picked amd64 over ARM for the boring reason that sharp breaks.
What made the move cheap was something built for an unrelated reason. Cloudflare already sat in
front of everything, and the edge Worker was origin-agnostic: it does fetch(request) and
passes through. Point the DNS at the box and the entire edge caching layer moves with it,
unchanged.
The cutover took thirty seconds to undo
The plan was to move only the Next app and leave the managed pieces alone. Neon, Meilisearch, R2, Upstash stay where they are. Self-hosting Postgres is the thing most likely to end a solo project, so version one would not touch it.
First cutover, pages took 41 seconds.
The app was now in Europe. Neon was in us-east-1. That is 105 ms between the server and the
database, multiplied by the several hundred queries a catalogue page makes. On Vercel it had
been invisible, because the functions ran in the same region as the database and I had never
had a reason to look.
This is not the same thing as the distance between a user and the server. That one is a single round trip per page and Cloudflare's edge already handles it. This was per query, and it was inside the page render.
DNS went back to Vercel in about thirty seconds, which is the only part of that evening I would repeat without changes. Then I did the thing I had explicitly ruled out and installed Postgres 17.10 on the box, matching Neon's version, and moved the database too.
I did try to keep it managed first. A European Neon instance turned out to be impossible: the
account lives in a Vercel-managed organisation, and the API answers
organization is managed by Vercel. Leaving the platform meant leaving the database with it,
whether or not that was the plan.
The dump took nine minutes, the restore thirty-five seconds, and a catalogue page went from 41 seconds to 0.54. No user data was lost. Fifty-four lazily-loaded track rows stayed behind on Neon and refill themselves on first visit.
If the app moves, the database moves. Managed Postgres in another continent is a decision you make on purpose or not at all.
What the box charged instead
Money went flat. Attention did not.
The next morning, the app started returning internal server errors on login.
ECONNREFUSED 127.0.0.1:5432. Postgres was not running. The disk was full and Postgres had
shut itself down when it could no longer write WAL. A few hours earlier it had been under
half.
Next's filesystem cache handler writes rendered pages into serverDistDir/app, which is
inside the release directory. Only FETCH-type entries go to .next/cache. I had
symlinked .next/cache to a shared directory and believed I had a persistent ISR cache
surviving deploys. What I actually had was a 23 MB directory of fetch results, while every
rendered page piled up inside the release.
The catalogue is 2.6 million pages and something was enumerating all of them. A fresh build is 1.3 GB. The live release reached 9.5 GB overnight. Deleting two old releases freed exactly 19 GB.
The fix is a cache-handler.js that runs two instances of Next's own FileSystemCache class
rather than reimplementing its storage format. Reads check the shared directory first and fall
back to the release, where the build's prerendered pages live. Writes always go to the shared
directory. The release went from 9.5 GB to 137 MB and stayed there, and the ISR cache genuinely
survives deploys now, which is the thing I had thought I already had.
Then deleting the old release took the site down in a different way. Hundreds of thousands of
inodes, and while rm ran, profile pages took 40 to 67 seconds. I ruled out disk because IO
wait showed 0%, which was wrong twice over: rm burns CPU too, and instantaneous %wa is a
bad instrument. ionice -c 3 and renice 19 took load from 5.4 to 2.3 and profiles back to
1.1 seconds.
A large rm on a production box does not run at normal priority.
The next night, the same crisis, and a better lesson
It happened again the following day, and the site fell over twice overnight.
Nearly all of the used space turned out to be in shared/isr. The first diagnosis had been right. The hours went
to refuting it three times, incorrectly, and this is the part of the night worth keeping.
Refutation one: "I deleted isr.old and the disk did not drop, so ISR is not the culprit."
The mv isr isr.old had failed silently. The install -d .../isr/server that ran afterwards
found the directory already there and only changed its owner, and chown updates ctime, so
the directory looked freshly created. isr.old never existed, the rm -rf deleted nothing,
and the whole of it sat untouched while I crossed it off the list. The tell was there: isr had a ctime
of 14:23 while the server directory inside it had 09:58. A new parent with an old child means
it was not created, it was chowned.
Check the exit code of every destructive step. "I deleted it and no space appeared" means it was not deleted, before it means anything else.
Refutation two: half-finished du output counted as evidence. With 5.7 million files, du
runs for minutes and hits a timeout, and I kept writing the partial output into the accounted
column. shared/isr was never measured at all. The prime suspect was the one blank cell in my
own table.
On a full disk, do not walk the tree top-down. Measure candidates in parallel, each into its own file, and let them finish out of order. The small ones return in seconds. The one that never returns is the answer.
Refutation three: I read the wrong column of lsof +L1 and concluded there were no
deleted-but-open files. That conclusion happened to be true, for a reason I had not actually
established.
The rule underneath all three: when df and du disagree, the question is not which directory
is large. It is why they disagree. Mount shadowing, deleted-open files, or a du that never
finished. Separate those three first.
There was also a real bug. The pruning script looked for cached pages with -type f, but a
cached page is four parts and .segments is a directory. The filter never saw roughly 40%
of every page, so no matter how often it ran it could not keep up.
The ceiling is not the number of cores
Once it was stable I wanted to know when to buy a bigger box, so I load-tested the live one with random artist slugs at rising concurrency. The result was not the one I expected: latency climbed while more than half the machine sat idle.
Next standalone runs a single process, and React server rendering is single-threaded. It saturates about one core and the others watch. Adding cores changes nothing at all until you add processes, which is exactly the upgrade I would have bought on intuition and exactly the money it would have wasted.
If you are sizing a self-hosted Next box, measure before you buy, and measure the thing that actually saturates rather than the thing the dashboard shows you.
Was it worth it
Yes, but not for the reason I started.
The money did what I expected: $208 a month of metered line items became a flat $7.92. What I did not price in was that Vercel had been absorbing a category of problem I had never had to think about, and the bill for that arrives as attention, in the middle of the night, in units of "Postgres is down and I do not know why yet."
If you are considering the same move, the honest test is not whether your bill is high. It is whether your bill is high for a reason you understand. Mine was not. I would have carried the broken ISR onto the box and paid for it in disk instead of dollars, which is roughly what happened anyway.
Measure first. Then move, if the workload really is the wrong shape for the pricing. Vercel was not the problem. It was just the place where the problem happened to be visible.