Ten Months of $0.00, Then $17.54: Moving xevrion.dev to Vercel

The GitHub student credit on my DigitalOcean droplet ran out without a word, so at 1am I moved the whole site to Vercel. What was on the box, what broke, and the one quoted env var that took every database route down.

·September 3, 2026·updated September 2, 2026·7 min read··view raw

this is the third hosting post for this site. first it was pm2 and a dist folder, then docker, then next.js in the same containers. now there are no containers. it's 2am. let's go.

I opened Gmail at half past midnight to clear some drafts and found three emails from DigitalOcean, each one a bit louder than the last. Past due. Payment failed. Pay $17.54 before Thursday or we suspend your account.

My first reaction was that this had to be a mistake. I have never paid DigitalOcean anything. The droplet was on the GitHub Student Developer Pack credit and had been since I made it.

Then I opened the billing history.

Eleven rows. Ten months of $0.00, then $17.54. The credit had been quietly eating the droplet's bill every month, and in August it ran out, and the droplet did what droplets do, which is keep running. No email saying the credit was gone. No warning that next month would be real money. Just an invoice.

$17.54 is not a lot of money. It is also more than I wanted to spend on hosting a portfolio that Vercel would host for free, and next month would be another $17.54, and the month after. So the question was never really "do I pay this," it was "why is this site still on a VPS at all."


What was actually on the boxCopied!

Before touching anything I ssh'd in, partly to back things up and partly because I honestly did not remember what was on there.

One droplet. 2 GB, one vCPU, Bangalore. 344 days of uptime, which I felt a bit sad about. And on it:

  • one container, the Next.js frontend image from GHCR, running for 25 hours since the last deploy
  • an ~/app folder with a docker-compose.yml and a .env full of production keys
  • host nginx doing SSL, gzip, security headers, and a 7-day proxy cache for the OG image route
  • a Docker volume called app_views_data with a views.json in it, last written in June
  • a ~/thy folder, which turned out to be the entire old Vite-era checkout, node_modules and all, still sitting there from April
  • a .pm2 directory from the era before that

Nothing was mounting that views volume anymore. Views moved to Neon during the Next.js rewrite and the file just sat there with 1,674 total reads frozen in it. The database says 5,392 now. I saved it anyway, along with the env file, the compose file, the nginx config, and the old views file from the Vite server for good measure. All of it went into a folder on my laptop with the date on it, because the rule for the night was destroy nothing until Vercel is serving the domain.


Why VercelCopied!

Because the site is Next.js and Vercel is free for this, and because there was nothing on the droplet that needed a server.

I went through the whole app looking for anything that assumed a writable disk or a long-running process. There was one candidate: a renderPostHTML function in src/lib/posts.ts that cached rendered blog HTML into a .post-cache folder. That would 500 on Vercel, where the filesystem is read-only.

Except nothing imports it. It's the disk cache from the old blog pipeline that the Next.js migration post complains about, still in the repo, never called. Blog pages are statically generated now. So the answer was that nothing needed the box, and the cache function is on the cleanup list where it has apparently been since June.

Everything else the site does is either static, or an API route that talks to something external: Neon for comments and views, Clerk for auth, Spotify, WakaTime, GitHub, OpenWeather. None of that cares where the code runs.


The migration itselfCopied!

This part was boring in a good way.

vercel link --yes --project thy-xev

That created the project and linked it to the GitHub repo, so every push to main deploys. Then twelve environment variables, pushed in from the droplet's .env for both production and preview. Then vercel deploy. Build took about a minute and a half, 87 static pages, done.

I hit every route I could think of. Home, blogs, weekly, places, games, OG images, sitemap, RSS, every old slug redirect, all 200. Spotify, WakaTime, GitHub contributions, location, all 200.

/api/views 500. /api/guestbook 500.


The Thing That Actually BrokeCopied!

The logs said this:

Error: Database connection string provided to `neon()` is not a valid URL.
Connection string: 'postgresql://neondb_owner:...@ep-...neon.tech/neondb?sslmode=require'

I read that three times. The URL looked completely fine. Correct host, correct params, same string that had been working in production for months.

Then I noticed the quotes in the error message were not the error message's quotes.

The DATABASE_URL line in the droplet's .env was written as DATABASE_URL='postgresql://...', with single quotes around the value. Docker Compose's env_file strips those quotes silently. So for months the container got a clean URL and nobody, including me, knew the quotes were there. I copied the file byte for byte into Vercel, Vercel does not strip quotes, and Postgres got a connection string whose first character was '.

Ten months of a bug that never got the chance to exist. The quotes were wrong the whole time. The only thing that changed was which env loader was reading them.

One sed to strip the quotes, re-add the variable, redeploy. Views came back with the live count, guestbook came back with all 19 entries.

A smaller thing on the same theme: the Vercel CLI refused to add NEXT_PUBLIC_OPENWEATHER_KEY at all. It looked at the name, decided "that looks like a credential and NEXT_PUBLIC_ exposes it to the browser," and made me pass --type config to say yes, I know, that is the point. Fair enough honestly.


Clerk would not work, and that was correctCopied!

With the database fixed I opened the preview URL and the guestbook had no sign-in button. Console said:

We were unable to attribute this request to an instance running on Clerk.
Make sure that your Clerk Publishable Key is correct.

The key was correct. The problem is what a Clerk production key actually is. Take the bit after pk_live_ and base64-decode it and you get clerk.xevrion.dev$. The publishable key is just the domain, encoded. A production Clerk instance only answers for the domain baked into it, and the preview was on something.vercel.app.

So sign-in can never work on a Vercel preview URL for this site, and that is by design, and the only way to test it is to point the real domain at Vercel and look. Which was the next step anyway.


Flipping the domainCopied!

DNS is on Cloudflare. The change was two A records, xevrion.dev and www, from the droplet's IP to 76.76.21.21, with the orange proxy cloud turned off because Vercel wants to terminate TLS itself. MX, SPF, the Google verification TXT, the Clerk CNAMEs, all untouched.

I did this part by hand in the Cloudflare dashboard, because after an hour of staring at a terminal I wanted to click at least one thing myself.

Within a minute:

server: Vercel, served from Mumbai. Certificate already issued. The guestbook had its sign-in button back, no console errors, avatars loading.

One side effect I had not thought about: ssh xevrion@xevrion.dev now tries to ssh into Vercel, which is not a thing. There is no server. There is nothing to ssh into. I added a droplet alias pointing at the raw IP for the last few minutes it was going to exist.


Destroying itCopied!

I read that sentence twice, thought about the backup folder on my laptop, thought about the 344 days of uptime, and clicked it.

Droplet deleted successfully. And with it: nginx, certbot, the OG proxy cache, the GHCR build workflow, the ssh deploy workflow, the compose file, the pm2 leftovers, the old Vite checkout. All of the infrastructure I wrote two blog posts about, gone in one click, replaced by git push.

The OG cache was the one I was slightly worried about, since nginx was caching those renders for a week and the deploy workflow used to warm it. Turns out the route already sends s-maxage=604800, which is the same week, and Vercel's CDN honours it. So the nginx layer had been duplicating a header the app was already sending.


Is it fasterCopied!

99 / 96 / 100 / 100. LCP at 0.9 seconds, layout shift at zero. I did not do anything to earn that beyond moving the same build from one 2 GB box in Bangalore to a CDN. The 96 on accessibility is a contrast thing I have been ignoring for a while and now have no excuse for.


What's leftCopied!

  • www.xevrion.dev currently serves the site instead of 301-ing to the apex the way nginx did. That's a dropdown in the Vercel domain settings and I have not clicked it yet.
  • The SERVER_IP and SSH_PRIVATE_KEY secrets are still sitting in the GitHub repo settings pointing at a server that no longer exists.
  • That renderPostHTML function is still in the repo. Still uncalled. Still on the list.
  • I wrote DigitalOcean support a very polite email asking whether they'd waive the $17.54 for a student whose credit expired. No answer yet. I'll pay it either way, it's real usage, but it didn't hurt to ask.

Was it worth itCopied!

It took about an hour, from ssh-ing in at one in the morning to the "deleted successfully" toast, and the only real bug was a pair of quote marks.

What I keep coming back to is how little was on that server. I dockerized it in April because deploys were fragile. I rewrote it in June and kept the containers because they were there. And the whole time the actual site was a static build plus a handful of API routes that talk to other people's services. The VPS was hosting my habit of having a VPS.

The bill was never the problem. The bill was just the first thing that made me look.

I do miss being able to ssh into my own website. Not enough to pay $17.54 a month for it.

Comments