How I cut a $35 Supabase bill to $0 with Cloudflare D1
Moving a live multi-tenant SaaS off Supabase Postgres onto Cloudflare D1 with zero downtime. The schema rewrite was the easy part. The date-coercion bug, the PostGIS audit, and the break-even math were not.
Earlier this year I moved a production app off Supabase and onto Cloudflare D1. Multi-tenant SaaS, 18 organizations, about 70 active sessions, auth and billing and usage metering all live. The Supabase Pro bill was $35 a month. The D1 bill is effectively zero.
Zero downtime, no data loss, and honestly, the schema rewrite everyone worries about was the easy part. What actually cost me time were three things nobody mentions: a date bug that disguises itself as an auth failure, raw SQL that typechecks and then dies at runtime, and a geospatial assumption I got wrong.
First, the boring truth about the money
D1 on the Workers Paid plan is $5 a month, which includes 50 million row writes. After that it's a dollar per million. Against a $35 Supabase bill, the break-even lands around 80 million writes a month. If you're above that, this migration saves you nothing.
I wasn't above it. I wasn't anywhere near it. My busiest writer was a usage-metering table that a Durable Object upserts into, and when I actually read the write volume off the dashboard it came to a few dozen row writes a month. Not million. Dozen.
That's the real lesson of the cost section: "D1 is cheaper" is not a fact, it's a break-even, and you only know which side you're on after you read your own dashboard. I nearly didn't check because the answer felt obvious. It was obvious, but in an embarrassing direction.
The bug that looks like broken auth
The first thing that broke after the driver swap was signup. Better-Auth started throwing FAILED_TO_CREATE_USER on every new account. Nothing in that error says "date", so I spent a while staring at the auth config.
The actual problem: drizzle-orm/d1 passes whatever you give it straight through to D1's .bind(), and D1 only accepts strings, numbers, null, and ArrayBuffers. JavaScript Date objects get rejected. Better-Auth hands the ORM real Dates for createdAt, D1 refuses the bind, and the failure surfaces three layers up wearing an auth costume.
The fix is a Proxy around the D1 driver that intercepts every .bind() and coerces on the way in: Date becomes an ISO string, boolean becomes 0 or 1.
function wrapD1ForDates(db: D1Database): D1Database {
return new Proxy(db, {
get(target, prop) {
if (prop !== 'prepare') return Reflect.get(target, prop);
return (query: string) => {
const stmt = target.prepare(query);
return new Proxy(stmt, {
get(s, p) {
if (p !== 'bind') return Reflect.get(s, p);
return (...values: unknown[]) => s.bind(...values.map(coerceForD1));
},
});
};
},
});
}
I'd call this optional if signup merely logged a warning. It doesn't. Without the proxy, user creation is just broken, and the error message sends you to the wrong layer. And if you run local dev on better-sqlite3 against a file database, congratulations, you get to patch the same coercion there too, at the Statement.prototype level, because it's the same class of bug in a different driver.
The rewrite is total, and that's fine
You cannot half-migrate a Drizzle schema. The moment the driver changes, everything that imports the schema breaks, so all ~30 tables moved in one commit. The substitutions are mechanical once you know them:
pgTablebecomessqliteTable- every
pgEnumbecomes a frozen TS tuple with a Zod enum derived from it, because SQLite has no enum type gen_ulid()column defaults become app-side$defaultFn(() => generateULID())text[]becomes JSON in a text column,jsonbbecomestext({ mode: 'json' })- booleans become
int({ mode: 'bool' }), timestamps become ISO-8601 strings
Tedious, yes. Risky, not really. The compiler walks you through it.
The dangerous part is the raw SQL, because it typechecks. vue-tsc has no opinion about whether your SQL dialect is right. Every ilike needs to become like. Every ::int cast has to go. jsonb ->> 'key' becomes json_extract(json, '$.key'), and comparing a JSON boolean means comparing against 1, because SQLite stores JSON true as an integer. None of this fails in CI. All of it fails when a real request hits it. Grep for the Postgres-isms explicitly; do not wait for users to find them.
The audit that actually matters
SQLite has no PostGIS, so before committing I audited what actually depended on it. I "knew" the only geospatial code was an admin-gated dataset feature. That was false. A public, CDN-fronted, unauthenticated tile endpoint was running a PostGIS MVT function on every request, on the read path, for everyone.
That discovery forced a real decision with three options: keep that one table on Postgres and run a hybrid (which quietly un-cancels the bill I was trying to cancel), rewrite the server-side MVT generation as client-side turf.js (a genuine product regression), or delete the feature. I traced its usage, found it wasn't load-bearing, and deleted it.
The point isn't the deletion. The point is that the migration decision was never really about tables. It was about tracing every consumer of the one capability the new engine cannot provide. My assumption covered the obvious consumer and missed the public one. Audit consumers, not schemas.
Was it worth it
The bill went from $35 to $5, and that $5 was already being paid for Workers. Marginal cost: zero. The app got simpler too. One platform, one deploy, the database bound directly to the Worker with no connection pooling story at all.
But I'd frame the tradeoff honestly: this worked because the write volume was tiny, the geospatial dependency turned out to be expendable, and the app was already living on Cloudflare. Change any of those and the answer changes. A migration like this is 20% schema rewrite and 80% finding the code paths that quietly depend on the engine you're leaving. The rewrite fits in a weekend. The finding is the job.