ARTICLE
Projects

A complete business system on Cloudflare D1: five services, one database

5 August 2026Majed Alandajani

Most of what is written about Cloudflare D1 is a getting-started guide: create a database, run a query, print the result. I could not find anything answering the question I actually had: can it run a real business, with contracts, invoices and customer data?

This is what I built, and my answer after operating it.

What actually runs

Five independent services sharing one database:

  • a static bilingual site
  • a services store
  • a blog rendered from the database
  • an API taking orders and payments
  • an operations panel behind an identity gate

The database: 33 tables built through 18 ordered migrations, with 7 triggers enforcing constraints the application cannot bypass.

Decision one: one database for everything

The usual alternative is a database per service. I rejected it because the truth fragments. The order lives in one place, the customer in another, the contract in a third. Then you need synchronisation between them, and synchronisation fails quietly.

With one database, a client's state is a single fact that every service reads. The price I paid: every service can technically read everything, so the access gate has to carry the protection that database isolation would have provided.

Decision two: guarantees live in the database

My audit log cannot be updated or deleted. Not because the code declines to, but because a trigger refuses.

I covered this in detail in an audit trail the application cannot rewrite: a trigger in the database aborts any update or delete against the log.

The difference matters. A constraint in code falls with the first bug. A constraint in the database survives the application above it being entirely wrong.

Decision three: the partial unique index

Some rows carry an external key and some do not. A plain unique index will reject the second row holding NULL on some engines. The fix:


CREATE UNIQUE INDEX ux_posts_sheet_key ON posts(sheet_key) WHERE sheet_key IS NOT NULL;

Any number of NULLs coexist, and the key stays unique wherever it exists.

How the thirty-three tables divide

They fall into five groups:

  • Commerce: orders, proposals, contracts, invoices, instalments, payments.
  • Relationship: customers, their records, their private links.
  • Operations: bookings, availability rules and their exceptions.
  • Correspondence: inbound and outbound mail, the notification log.
  • Governance: the audit trail, the approvals record, system settings.

The last group is what turns an application into a business system, because it answers "who did what and when" months later.

Managing eighteen migrations without disaster

Each migration is a file numbered with leading zeros, so alphabetical order gives chronological order.

An applied migration is never edited. A correction is a new migration, or your database diverges from every other environment's.

Every migration also gets a rehearsal. I keep a staging database with the same schema and no customer data; the migration runs there, and the schema is checked afterwards, before production is even offered. That check earns its keep because a migration can succeed and still leave a database that cannot answer.

The traps that cost me time

Do not mix ? and ?1 in one statement. SQLite reuses index 1 and you get a wrong binding with no error.

Two time formats exist between the CLI and the evidence tables, and the comparison is textual, not chronological.

What running at the edge decides

This decision reaches past the technical. A database at the edge means you do not run a server, which removes from your schedule: upgrades, security patches, disk monitoring, capacity reservation, and a fixed monthly bill you pay whether it is used or not.

The price is that you work inside constraints you do not control. Execution time is capped. There is no filesystem. The runtime is not complete, and some libraries will not run. Check those limits before you start building.

Where D1 is the wrong fit

D1 is SQLite, and it suits what SQLite suits: many reads, reasonable writes, structured data. That describes most business systems.

It is the wrong engine for heavy concurrent writes, above all very heavy concurrent writes to the same row. Long heavy processing sits outside its limits, and so do heavy analytical queries and very large data, anything at a scale SQLite does not suit. If one of those describes your workload, the problem is the choice itself, and no implementation will fix it.

The conclusion

Yes, it works. It keeps working as long as the guarantees live in the schema and every migration is rehearsed before it reaches production.

Related reading

Have a project in mind?
Tell me what you want to build. Your first 15 minutes of consulting are free.
Book a consultation