I Moved My Entire Stack from a $20/Month VPS to Cloudflare Free Tier — 30-Day Bill: $0
Last month I was paying $20/month for a VPS that mostly sat idle. This month I'm paying $0. Here's exactly what I moved and how.
The Setup I Replaced
| Service | Before | After | Monthly Cost |
|---|---|---|---|
| API backend | 2GB VPS (Ubuntu) | Cloudflare Workers | $20 → $0 |
| Static site | Same VPS | Cloudflare Pages | included → $0 |
| Cron jobs | Cron on VPS | Cloudflare Cron Triggers | included → $0 |
| Database | SQLite on VPS | Turso (9GB free) | included → $0 |
Total: $240/year → $0/year
What I Actually Moved
1. API Backend → Cloudflare Workers
My old Express.js server handled ~50K requests/month. I rewrote it as a Worker:
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/api/health') {
return new Response(JSON.stringify({ status: 'ok', timestamp: Date.now() }), {
headers: { 'Content-Type': 'application/json' }
});
}
if (url.pathname === '/api/users' && request.method === 'GET') {
const { results } = await env.DB.prepare('SELECT * FROM users LIMIT 100').all();
return Response.json(results);
}
return new Response('Not found', { status: 404 });
}
};
Cold start: 0ms (vs ~200ms on VPS with PM2 restarts)
Free tier limit: 100,000 requests/day — I use ~1,700/day
2. Cron Jobs → Cloudflare Cron Triggers
I had 3 cron jobs: daily backups, weekly reports, hourly health checks.
// wrangler.toml
[triggers]
crons = ["0 2 * * *", "0 9 * * MON", "0 * * * *"]
// worker.js
export default {
async scheduled(event, env, ctx) {
switch(event.cron) {
case "0 2 * * *":
await runBackup(env);
break;
case "0 9 * * MON":
await generateWeeklyReport(env);
break;
case "0 * * * *":
await healthCheck(env);
break;
}
}
};
VPS cron problems I don't miss:
- Server timezone drift (my backups ran at 3 AM instead of 2 AM for a week)
- No retry logic — if the server hiccupped, the job just didn't run
- Logs scattered across
/var/log/syslog
3. Database → Turso (SQLite at the Edge)
My 200MB SQLite database moved to Turso's free tier:
// libsql client
import { createClient } from '@libsql/client';
const db = createClient({
url: env.TURSO_URL,
authToken: env.TURSO_AUTH_TOKEN
});
// Same SQL, works everywhere
const users = await db.execute('SELECT * FROM users WHERE active = 1');
Free tier: 9GB storage, 500 databases, 1 billion row reads/month
The Gotchas
Cloudflare Workers limits:
- 10ms CPU time per request (free tier) — fine for APIs, not for video encoding
- No WebSocket support on free tier (I don't use them)
- Environment variables encrypted at rest, but you can't log them
Turso limits:
- 9GB total across all databases — plenty for my use case
- No persistent connections (HTTP-based) — adds ~5ms latency vs local SQLite
Performance Comparison
| Metric | VPS ($20/mo) | Cloudflare Free |
|---|---|---|
| API response time (p50) | 45ms | 12ms |
| API response time (p99) | 180ms | 45ms |
| Uptime | 99.2% (3 restarts) | 100% (30 days) |
| Deploy time | 2 min (git pull + restart) | 15 sec (git push) |
What I Still Pay For
- Domain: $12/year (Cloudflare Registrar, at-cost pricing)
- Email forwarding: $0 (Cloudflare Email Routing, free)
The Real Win Isn't the Money
It's the operational simplicity. No SSH, no apt update, no worrying about disk space, no 3 AM "server is down" alerts. My infrastructure is now 3 files in a Git repo.
What's your experience with serverless free tiers? Have you hit any limits that forced you back to a VPS?
P.S. If you're looking for more free dev tools, I maintain a list at Free Dev Resources — no signup required.
Top comments (0)