We moved our entire corporate platform off Supabase onto Cloudflare D1 in a two-week engineering window. This is the exact playbook: the porting conventions, how we replaced row-level security without losing safety, and the operational wins that made the migration stick.
When we decided to rebuild intactic.net as a fully Cloudflare-native platform, the biggest piece of the move was not the frontend — it was the database. Our production data lived in Supabase Postgres with row-level security policies, Supabase Auth, and a handful of JSONB columns. This post documents the migration exactly as we executed it, so the next team attempting a Postgres-to-D1 port does not have to rediscover the conventions under pressure.
The port starts with a decision that shapes everything else: D1 is SQLite, so you choose app-level conventions for the things Postgres gave you for free. We documented three of them in the migration itself. UUIDs become TEXT, generated by the application with crypto.randomUUID() instead of by the database. Timestamps become ISO-8601 TEXT in UTC, set by the application layer rather than by NOW() defaults. JSONB becomes TEXT holding JSON strings, encoded and decoded at the application boundary. None of these are compromises — they make every row self-describing and portable, which is exactly what you want in a database you might someday move again.
Row-level security was the part we rethought completely. Supabase RLS evaluates policies inside the database; D1 has no equivalent, and for our admin surface that turned out to be a feature. Instead of policies scattered across tables, we enforce authorization in exactly two places: the admin layout gates every page, and every /api/admin route handler independently calls verifyAdmin() against the D1 session store. The security model became greppable — you can read the whole boundary in an afternoon, which is more than we could say for forty lines of SQL policies.
Admin authentication moved from Supabase Auth to a D1-backed session model. admin_users stores PBKDF2-SHA256 password hashes with 100,000 iterations, derived entirely with Web Crypto so the identical code runs in the Workers runtime and under Node during local development. Sessions are opaque 256-bit random tokens stored only as SHA-256 hashes, carried in an httpOnly cookie with a seven-day TTL. No JWTs, no third-party auth dependency, and password hashing that costs nothing at the edge.
Operationally, the payoff arrived immediately. In the deployed Worker, the database is a binding — no connection strings, no connection pooling, no network hop to an external Postgres. For local development and CI, src/lib/d1.ts falls back to the D1 HTTP API using CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, so the same code path runs everywhere. D1 read replication through the Sessions API keeps public reads fast from regional colos, and our production queries now execute from the same network that terminates the request.
We made the migrations and seeds idempotent from day one — every file in d1/migrations applies with INSERT OR IGNORE or CREATE IF NOT EXISTS semantics, so CI can replay the full schema plus seeds into a fresh environment on every build. That single decision is why our deploy pipeline can rebuild production from a clean runner in minutes.
What would we do differently? Only one thing: we would have written the performance indexes before the migration rather than after. Our 0004 migration added composite indexes for the public list queries — WHERE is_published = 1 ORDER BY sort_order on catalog tables, and status plus published_at on blog posts — and those would have been cheap to include in the initial port. The lesson generalizes: when you port a schema, port the query plans, not just the tables.