Ahmet Arslan

We run Hizmetgo, a Turkish local-services marketplace (think Thumbtack for Türkiye). The site is programmatic-SEO heavy: ~1M+ Next.js ISR pages across 81 provinces, 970+ districts and thousands of service categories. Here's the infrastructure story of how we left Vercel, moved to a single Hetzner VPS, hit a disk-full crash at 3am, and fixed it with an in-memory cache handler.

Why we left Vercel

Vercel was excellent for shipping fast, but at our page volume the ISR invocation + bandwidth bill climbed past what a bootstrapped startup wanted to pay (~$150/mo trending up). We wanted predictable cost and control, so we moved to a Hetzner VPS with Coolify (an open-source, self-hosted Vercel/Heroku alternative) for git-push auto-deploys, with Cloudflare in front for caching and TLS.

The migration itself was smooth: Coolify builds the Next.js app (Turbopack), a webhook auto-deploys on every push to main, and Cloudflare's "Cache Everything" absorbs the bot storm on our SEO pages.

The 3am disk-full crash

A few days in, the container started crashing. df -h showed the disk at 100%. The culprit was subtle: Next.js ISR writes each rendered page to the filesystem cache — and with ~1M crawlable pages, Googlebot plus our own crawl was generating ~145GB/day of cache files inside the running container's writable layer. docker system prune couldn't reclaim it (the files belong to the running container, not a stopped one), so the disk just filled until the box fell over.

The fix: an in-memory cache handler

Next.js lets you swap the ISR cache backend via cacheHandler in next.config. We pointed it at a small in-memory handler so the cache lives in bounded RAM instead of unbounded disk:

// cache-handler.js
const cache = new Map(); // swap for lru-cache in prod to bound memory
module.exports = class CacheHandler {
  async get(key) { return cache.get(key) ?? null; }
  async set(key, data, ctx) {
    cache.set(key, { value: data, lastModified: Date.now(), tags: ctx?.tags });
  }
  async revalidateTag() { /* clear by tag */ }
};

Enter fullscreen mode Exit fullscreen mode

// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // disable the default in-process cache; we manage it
};

Enter fullscreen mode Exit fullscreen mode

Plus a COPY cache-handler.js line in the Dockerfile so it ships with the standalone build. Result: even if all 1M pages get crawled in a day, the disk never fills — the cache is capped in memory, and Cloudflare handles the long-tail hit rate at the edge.

Other things that helped

  • Cloudflare edge caching on the SEO path so cold long-tail TTFB stays sane.
  • Prisma connection_limit tuned down for a single VPS (vs serverless fan-out) so we don't exhaust the Postgres (Supabase) pool.
  • A dedicated read path for SEO pages so a crawl wave can't starve auth.

If you're self-hosting a large ISR Next.js site, watch the cache write path before it watches you. Our data pages (e.g. the Türkiye service price index) are all served this way now — 1M pages, one modest box.


Stack: Next.js 16 · Prisma · Supabase (Postgres) · Expo (mobile) · Cloudflare · Hetzner + Coolify · Turbopack.