Skip to content

From Vercel to Hetzner and Coolify: why I moved back home

Vercel is lovely to get started with, but at some point I found myself paying for magic I didn't need, and for data I wanted to keep in the EU. Here's the story of how I moved Søknadsbasen to a single Hetzner server running Coolify, what was actually hard, and what I'm left with.

Why move at all?

I have nothing bad to say about Vercel. It's probably the best developer experience out there, and for a side project the free tier is more than enough. But Søknadsbasen grew from "side project" into "something people actually pay for", and that's when three things started to grate:

  1. Cost and predictability. Serverless is cheap right up until it isn't. PDF generation with headless Chrome eats memory, and every function with a high memory ceiling costs.
  2. Data storage in the EU. I want to be able to say honestly on the privacy page that data lives in Europe. With my own server in Germany I know exactly where things are.
  3. Things serverless doesn't like. I have a WebSocket service for real-time collaborative CV editing. Long-lived connections and serverless aren't best friends.

The solution became a single Hetzner server in Falkenstein, with Coolify as "my own little Vercel" on top.

What is Coolify?

Coolify is an open-source PaaS you run yourself. You point it at a Git repo, it builds with Nixpacks or a Dockerfile, and it handles domains, Let's Encrypt certificates, environment variables and deploys. You get a lot of the Vercel feeling, but on hardware you own, at a fixed monthly price.

In my case: one CPX41 at Hetzner runs Coolify, the Next.js app itself, and the collab server, all on the same box.

The easy part

The server itself takes ten minutes. Order it, SSH in, and run:

curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash

The Coolify dashboard comes up, you connect GitHub, pick a repo, and you're almost there. Certificates and domains are a couple of clicks.

The part that actually took some thought

Here are the things I wish someone had told me up front.

1. Next.js has to build "standalone"

For a slim Docker image I set:

// next.config.ts
const nextConfig = {
  output: "standalone",
  // ...
};

Next then puts everything the server needs in .next/standalone, and the image stays small.

2. NEXT_PUBLIC variables are baked in at BUILD time

This is the classic trap. Anything starting with NEXT_PUBLIC_ gets inlined into the client bundle when you build, not at startup. On Vercel that happens automatically. In a Dockerfile you have to pass them in as build args:

ARG NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
RUN npx next build

Forget this and the app builds fine, but the browser gets empty values and everything breaks in production.

3. Headless Chrome in a container

My PDFs are generated with Puppeteer. On Vercel I used a serverless-specific Chromium package. In a regular container you're better off installing system Chromium and pointing Puppeteer at it:

RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium fonts-liberation libnss3 libgbm1 libasound2 # ...
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

And the code picks the right browser based on the environment, so local development and production behave the same.

4. Migrations at startup, not at build

The build step doesn't have access to the database, and it shouldn't either. So I run the migrations in an entrypoint when the container starts:

#!/bin/sh
set -e
prisma migrate deploy
exec node server.js

5. Cron isn't free anymore

Vercel Cron was just a few lines in vercel.json. With Coolify it became "Scheduled Tasks" that call my own endpoints with a secret key:

curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
  https://soknadsbasen.no/api/cron/jobs-sync

While I was at it, I made the cron routes "fail-closed": without the right secret they respond 401. Better that a forgotten variable stops the job than that anyone at all can trigger it.

6. The long-lived WebSocket service

The real-time collaboration (Yjs over Hocuspocus) is a separate process that just needs to sit there and run. This is exactly the kind of thing serverless makes hard, and an always-on container makes trivial. On Coolify it was just an extra "resource" with its own subdomain and its own Dockerfile.

Don't forget the moving boxes

The app itself is half the job. The rest is everything around it:

  • Pull the environment variables out of Vercel: vercel env pull.
  • Point the Stripe and email webhooks at the new domain, and update the signing secrets.
  • Add the new domain to Supabase's auth configuration.
  • Lower the DNS TTL a day before you move, so the cutover goes fast.

And most important of all: keep Vercel running until everything is verified. I tested login, AI, PDF and a real test payment on the new setup before touching DNS. Then rollback is just pointing the domain back.

What am I left with?

I own the operations now. That's both the point and the price. I have to think about updates, backups and monitoring myself, things Vercel hid from me. In return I get predictable cost, data in the EU, full control over long-lived processes, and a setup I understand all the way down.

For a small, paying product it was worth it. For a weekend prototype I would have stayed on Vercel.

Recommendation

If you're considering the same: start by making the app Docker-ready while you're still on Vercel. Get docker build working locally first. Once the image builds and runs on your machine, the rest is just a server, a domain and a bit of patience.

This text is machine translated and has not been reviewed yet.

If you liked this

Subscribe via RSS or JSON Feed, or get in touch.

Share on: LinkedIn · Redi AS · Spotify · Instagram · Wikidata · GitHub (work) · GitHub (personal)