The $20 I couldn't cancel
This is a story about how far I got into a migration before I stopped to ask whether I needed to do it at all — the answer was forty lines of YAML, and the more useful part is why I nearly wrote a weekend of work instead. It starts with a bill. The projects that earn their keep were already off Vercel. I'd moved the active apps onto Cloudflare Workers for real runtime reasons — a story I told separately, and it was not a redeploy. What was left was the tail: a dozen or so mostly-Next.js side projects nobody has opened in a year — old demos and small tools, one link away from someone needing them. I don't want to delete them; I just want them reachable.
They cost essentially nothing to run — they idle. And yet Vercel wanted $20 a month, about $32 CAD, because keeping them the way they were kept me on the Pro plan. I wanted to cancel the subscription and leave, and this dormant tail was the only thing in the way. So I decided to move it too, and be done.
The migration I scoped
The obvious target was Cloudflare Pages, and I priced the move honestly. Per project: the Next.js adapter work, the redirects, the environment variables re-entered, DNS re-pointed — then the long tail nobody accounts for, re-verifying a dozen apps I haven't touched in a year, half of which I'd have to relearn before I could confirm they still worked.
None of it was hard. All of it was time, multiplied by a dozen, for zero new capability — the same dormant apps, hosted elsewhere. Cloudflare Pages is a fine platform; I'd just moved my important apps to its sibling. It was the wrong-sized tool for a graveyard. And here's the tell I missed: scoping that work felt like progress. I had a checklist. I was moving.
I was pricing the escape in careful detail. I had not spent one minute pricing the thing I was escaping.
What is the $20 actually buying?
So, almost as an afterthought, I asked it. What exactly is the $20 buying? Not compute; these projects don't use any. Not bandwidth; Hobby's is generous. Not a limit I was near. When I read Vercel's terms instead of treating the pricing page as a wall, the charge resolved to one narrow thing:
That's the whole paywall. Private and org-owned and deployed via the Git integration. Remove any single one and Hobby works — a public repo, a repo under my personal account, or a repo that never touches the Git integration all deploy free. The bill wasn't for hosting. It was for one code path.
The CLI skips the turnstile
Because the restriction lives entirely in the Git integration — the automated GitHub-to-Vercel bridge that gives you push-to-deploy and preview comments. Deploying the exact same code with the Vercel CLI doesn't route through that bridge. A token, vercel pull to fetch the project's settings, vercel build, vercel deploy --prebuilt. Same account, same project, same free plan — the org-repo rule never fires, because there's no Git connection for it to fire on.
The usual contortions I'd considered first were all worse, because each bends the repo to fit the integration:
- Make the repos public. They're private on purpose. Non-starter.
- Transfer them to my personal account. Solves a billing quirk by making the org structure wrong — ownership no longer sits where it should.
- Fork to a personal account. A fork of my own private code that immediately starts drifting from its source.
- Mirror-push to a fresh private repo. A second source of truth to keep in sync, forever, for the life of the project.
All four twist the repository so the Git integration will accept it. The CLI path is better precisely because it doesn't need the integration to accept anything.
Forty lines of YAML
So the migration I'd scoped collapsed into a GitHub Actions workflow. Push to main, Actions builds and deploys through the CLI, the project stays on Hobby.
name: Deploy to Production (Vercel) # Deploys to the existing Vercel project via the Vercel CLI (prebuilt flow), # so the repo stays on the Vercel Hobby (free) plan even though it lives in a # GitHub org. The native Vercel Git integration MUST be disconnected — see # SETUP.md — or every push will deploy twice. on: push: branches: [main] workflow_dispatch: {} # One in-flight production deploy at a time; a newer push supersedes an # older, still-running one. concurrency: group: vercel-production cancel-in-progress: true # The Vercel CLI reads the org/project from the environment. The token is # passed explicitly to each vercel command. env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Node uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Install dependencies run: npm ci # --- Pre-deploy gates --- - name: Typecheck # hard gate — a type error is a real break run: npm run typecheck - name: Lint # soft — a style warning shouldn't block a deploy run: npm run lint continue-on-error: true - name: Test # hard gate run: npm test # --- Vercel prebuilt deploy. Root Directory arrives via `vercel pull`, --- # --- so this works unchanged for each project in a monorepo. --- - name: Install Vercel CLI run: npm i -g vercel@latest - name: Pull Vercel environment & project settings run: vercel pull --yes --environment=production --token="$VERCEL_TOKEN" env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Build project artifacts run: vercel build --prod --token="$VERCEL_TOKEN" env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Deploy prebuilt artifacts to production run: vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN" env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
vercel build then deploy --prebuilt builds the artifact in Actions and ships it — Vercel doesn't rebuild, so what you tested is what deploys.A few choices are load-bearing. I used the official CLI, not a third-party deploy action — it's what Vercel supports, and one fewer dependency to trust with a deploy token. The env vars (VERCEL_ORG_ID, VERCEL_PROJECT_ID, token) are how the CLI targets the existing project without any Git link. And the top comment isn't optional: disconnect the native Git integration, or every push deploys twice.
What I gave up
Exactly one thing bit during setup. My first version gated the deploy on lint, and a couple of dormant projects had stale lint config that failed — blocking a deploy of code that ran perfectly. Lint isn't a correctness gate; a rule about import order doesn't mean the app is broken. So lint became a non-blocking warning while typecheck and tests stayed hard gates — the continue-on-error: true above.
The larger cost is worth stating plainly, because the Git integration was doing real work I now don't get:
- GitHub Deployments API status checks on commits.
- Preview comments and the Vercel Toolbar on pull requests.
- Automatic per-branch preview URLs — every branch used to get one free.
- Fork PRs — gone entirely; a token-based workflow won't safely run on them.
- Auto-promote on revert, and the rest of the dashboard-native niceties.
- The monorepo Ignored Build Step. For the one repo where several Vercel projects share a monorepo, I now own a workflow per project — each with its own Root Directory via
vercel pull— instead of letting the integration decide what changed.
Plus a couple of YAML files and a pinned Node version per repo, which I maintain. For a dormant project I use approximately none of that. For an actively developed one, I'd miss all of it.
Different projects, different answers
Which is the real conclusion, and it isn't "the CLI beats the Git integration." The right answer is a function of the project:
- Truly dead. Disconnect the Git integration and stop. The last deployment keeps serving indefinitely — no workflow, no token, no cost.
- Occasionally updated. The CLI by hand, or the workflow above. Free, deploys on push, minimal ceremony.
- Actively developed by a team. Pro is genuinely the better product. The previews, the checks, the toolbar, the fork PRs are worth $20 a month and then some. Pay for it — I do, for the projects that actually use it.
One caveat I won't bury: the Hobby plan is non-commercial under Vercel's fair-use terms. This was the right call because these are personal, non-revenue projects. If something makes money, it's a business, and the answer is Pro — not a clever workflow. Don't read this as a way to run commercial workloads on the free tier; you'll get flagged, and fairly.
Why I didn't check sooner
Here's the part that actually nags at me. The check took one conversation. The migration I'd scoped would have taken a weekend. Why did I reach for the expensive thing first?
Because verifying the premise looked more expensive than acting on it — an afternoon of reading docs for an uncertain payoff, versus a checklist I could start ticking immediately. Scoping the move felt like progress; reading the terms felt like procrastination. So I started moving instead of asking why.
That calculus is what changed, and it's where I'll be careful about AI, because it's easy to overclaim. Pointed at the right question, an AI assistant put the private-org-repo restriction in front of me in one exchange, with the documentation to confirm it. Its recall across Vercel's docs is broader than mine will ever be — that's real, and it's why consulting it beats sitting alone with an assumption.
AI didn't know something I couldn't have found. It made finding it cheap enough that I finally bothered.
But the value wasn't authority. It makes mistakes; I've watched it be confidently wrong. What made this work is that the answer was verifiable — Vercel's terms state the restriction plainly once you know what to look for, and the workflow either deploys on Hobby or it doesn't. The AI was fast at locating and framing the answer, not the source of its truth. The useful question was never "what should I migrate to." It was "why am I being charged" — and I'd skipped it for so long only because asking used to cost an afternoon. Now it costs a sentence. When checking an assumption is nearly free, assuming stops being the rational default.
The wrong surface, not the wrong vendor
The general shape — the only part that travels past one product's quirk — is that pricing tiers are drawn around a product surface, the convenience layer, not the capability underneath. I was paying for the Git integration's polish, not for hosting. The capability I needed sat in the free tier the whole time; I just kept reaching for it through the paid door.
So when a bill feels mispriced against what you're getting, it's worth asking whether you're in the wrong surface before you go shopping for a new vendor. The migration is the expensive answer. The cheap one is usually a paragraph in the docs you never had a reason to read — until reading it got cheap.
What's one assumption in your own backlog you've been quietly building around instead of testing?