---
title: "Ten Months of $0.00, Then $17.54: Moving xevrion.dev to Vercel"
description: "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."
date: 2026-09-03
tags:
  - vercel
  - deployment
  - vps
  - web
image: '/blog/vercel-migration-lighthouse.png'
---

> this is the third hosting post for this site. first it was [pm2 and a dist folder, then docker](/blogs/dockerizing-portfolio), then [next.js in the same containers](/blogs/nextjs-migration). 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. <span class="bad">Pay $17.54 before Thursday or we suspend your account.</span>

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.

![DigitalOcean billing history: every invoice from October 2025 to August 2026 is $0.00, then September 2026 is $17.54](/blog/vercel-migration-billing.png)

Eleven rows. <span class="key">Ten months of $0.00, then $17.54.</span> 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 box

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

![DigitalOcean droplet list with the past due banner on top: thy-xev, 2 GB, 1 vCPU, 70 GB, BLR1, created 11 months ago](/blog/vercel-migration-droplet.png)

One droplet. 2 GB, one vCPU, Bangalore. <span class="muted">344 days of uptime, which I felt a bit sad about.</span> 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

<span class="warn">Nothing was mounting that views volume anymore.</span> 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 <span class="key">destroy nothing until Vercel is serving the domain.</span>

---

## Why Vercel

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. <span class="muted">It's the disk cache from the old blog pipeline that the Next.js migration post complains about, still in the repo, never called.</span> 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 itself

This part was boring in a good way.

```sh
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. <span class="good">Home, blogs, weekly, places, games, OG images, sitemap, RSS, every old slug redirect, all 200.</span> Spotify, WakaTime, GitHub contributions, location, all 200.

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

---

## The Thing That Actually Broke

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. <span class="key">Docker Compose's `env_file` strips those quotes silently.</span> 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 `'`.

<div class="callout warn">

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.

</div>

One `sed` to strip the quotes, re-add the variable, redeploy. <span class="good">Views came back with the live count, guestbook came back with all 19 entries.</span>

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. <span class="muted">Fair enough honestly.</span>

---

## Clerk would not work, and that was correct

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$`. <mark>The publishable key is just the domain, encoded.</mark> A production Clerk instance only answers for the domain baked into it, and the preview was on `something.vercel.app`.

So <span class="warn">sign-in can never work on a Vercel preview URL for this site</span>, 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 domain

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:

![Browser dev tools showing a request to https://xevrion.dev/ with remote address 76.76.21.21 and a Server: Vercel response header](/blog/vercel-migration-headers.png)

`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 it

![DigitalOcean destroy dialog: This is irreversible. We will destroy your Droplet and all Droplet data will be scrubbed and irretrievable.](/blog/vercel-migration-destroy.png)

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

![DigitalOcean toast: Droplet deleted successfully](/blog/vercel-migration-deleted.png)

<span class="good">Droplet deleted successfully.</span> 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 faster

![Lighthouse for https://xevrion.dev/: Performance 99, Accessibility 96, Best Practices 100, SEO 100. First Contentful Paint 0.6s, Largest Contentful Paint 0.9s, Total Blocking Time 60ms, Cumulative Layout Shift 0](/blog/vercel-migration-lighthouse.png)

<span class="key">99 / 96 / 100 / 100.</span> 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 left

- `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. <span class="warn">No answer yet.</span> I'll pay it either way, it's real usage, but it didn't hurt to ask.

---

## Was it worth it

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.

<blockquote class="pull">
The bill was never the problem. The bill was just the first thing that made me look.
</blockquote>

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