One Database Per User with Cloudflare Durable Objects and Drizzle ORM
I’m building a production-ready app entirely on the Cloudflare Developers Platform (Basebrain) and I want to share implementation details of the key patterns I use.
Today, I’m focusing on a specific pattern that’s surprisingly simple to implement: creating one isolated database per user using Durable Objects and Drizzle ORM.
Durable Objects are one of the most powerful primitives on the Cloudflare Developer Platform, but I have seen many people struggle to define them. I see them as mini servers without the ops.
Each object (server) is:
- Addressable by a unique name/ID, like a key in a distributed system.
- Run in a single location at any given time, so no race conditions.
- Can store persistent state, backed by Durable Object Storage.
- Can coordinate between multiple clients, like a chat room, game session, or rate limiter.
They’re great when you need strong consistency, low-latency coordination, or lightweight stateful logic.
Durable Objects provide a convenient SQLite API, and when combined with Drizzle ORM, give you a clean, type-safe way to create a database per user in your applications.
One Database Per User
Before looking at implementation, let’s understand why you might want one database per user:
- Scalability - Sharding a database into one instance per user addresses the limitations of storing and managing large datasets on a single database server
- True isolation - Each user’s data is completely isolated
- Simplified access control - No complex filtering or multi-tenancy code
- Performance - Durable Objects are distributed globally, and instanciated closest to the first request they receive. As such, each database will be instanciated closes to its users, significantly reducing end-to-end latency
In this example, we’ll build a simple notes API where each user gets their own SQLite database. A user can create, read, list, or delete notes. They will be able to interact exclusively with their own database. By definition, a user will not be able to access another user’s data.
The complete code for this tutorial is available on GitHub.
Throughout this example, we’ll assume you’re familiar with deploying Cloudflare Workers and we will focus on Durable Objects.
Project Overview
Let’s start with an overview of the project. We’ll be using:
- Cloudflare Workers - Our serverless platform
- Durable Objects - To maintain per-user databases
- Drizzle ORM - For type-safe database operations
- Hono - A lightweight framework for our API routes
Here’s the basic structure of the project:
1.2├── bindings.ts # Bindings TypeScript Definition3├── package.json # Dependencies and scripts4├── src5│ ├── db # Database-related code6│ │ ├── index.ts # CRUD operations7│ │ ├── notes.ts # Schema definition8│ │ ├── schemas.ts # Schema exports9│ │ └── types.ts # TypeScript types10│ └── index.ts # Main Worker code and Durable Object11├── tsconfig.json # TypeScript configuration12└── wrangler.json # Cloudflare Workers configuration
Bootstrapping the Durable Object
Let’s start with bootstrapping our Durable Object code.
Each Durable Object is part of a Durable Object Namespace. A Durable Object Namespace is how you reference a Durable Object class from your Worker. It’s like a binding that lets you create or execute methods in the Durable Objects from your Worker. A Durable Object Namespace must be bound to your Worker before you can instanciate Durable Objects.
After creating a Node.js project with npm
and configuring TypeScript, let’s create our wrangler.json
file to configure our Worker and Durable Object Namespace.
1{2 "name": "durable-objects-database-per-user",3 "compatibility_date": "2024-11-12",4 "workers_dev": true,5 "upload_source_maps": true,6 "observability": {7 "enabled": true8 },9 "main": "./src/index.ts",10 "migrations": [11 {12 "new_sqlite_classes": [13 "DurableDatabase"14 ],15 "tag": "v1"16 }17 ],18 "durable_objects": {19 "bindings": [20 {21 "class_name": "DurableDatabase",22 "name": "DurableDatabase"23 }24 ]25 }26}
Our Worker script name will be durable-objects-database-per-user
, it will have a worker.dev
URL, and observability enabled. Our Durable Object class will be DurableDatabase
and we enabled the Durable Object SQLite API on it.
Next, let’s write the code for our Worker and our Durable Objects.
1import { DurableObject } from 'cloudflare:workers';2import { Hono } from 'hono'3import { Bindings } from '../bindings';4
5const app = new Hono<{ Bindings: Bindings }>();6
7app.get('/', async (c) => {8 return c.json({ message: "Hello World!" })9});10
11export default {12 async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {13 return app.fetch(request, env, ctx);14 },15};16
17export class DurableDatabase extends DurableObject {18 constructor(ctx: DurableObjectState, env: Bindings) {19 super(ctx, env);20 }21}
It’s a Hono API, and the DurableDatabase
doesn’t do anything yet. We will come back to implement its methods.
We import Bindings
from our bindings definition file. The Bindings
type enables us to have type-safety and auto-completion when interacting with resources bound to the Worker.
1import type { DurableDatabase } from "./src/index.ts";2
3export type Bindings = {4 DurableDatabase: DurableObjectNamespace<DurableDatabase>;5};
We are pointing out to TypeScript that there’s a Durable Object Namespace called DurableDatabase
implementing the Durable Object class DurableDatabase
bound to our Worker.
Database Schema
Now let’s define our database schema. Let’s keep it simple: a note has an ID, a text content, and created and updated timestamps.
We use the sqliteTable
function from Drizzle ORM to create schema for our table:
1import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";2
3export const notes = sqliteTable(4 "notes",5 {6 id: text("id")7 .notNull()8 .primaryKey()9 .$defaultFn(() => `note_${randomString()}`),10
11 text: text("text").notNull(),12
13 created: integer("created", { mode: "timestamp_ms" })14 .$defaultFn(() => new Date())15 .notNull(),16 updated: integer("updated", { mode: "timestamp_ms" })17 .$onUpdate(() => new Date())18 .notNull(),19 },20);21
22// Helper to generate random IDs for our notes23function randomString(length = 16): string {24 const chars = "abcdefghijklmnopqrstuvwxyz";25 const resultArray = new Array(length);26
27 for (let i = 0; i < length; i++) {28 const randomIndex = Math.floor(Math.random() * chars.length);29 resultArray[i] = chars[randomIndex];30 }31
32 return resultArray.join("");33}
Alongside our schemas, we need to define the TypeScript types of notes saved in the database. We can infer those from the schema thanks to Drizzle $infer
properties.
1import type * as schema from "./schemas";2import { notes } from "./notes";3
4export type Note = typeof notes.$inferSelect;5export type InsertNote = typeof notes.$inferInsert;
We also create a type for our Database, such that we can easily use it when making database operations.
1import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";2import type * as schema from "./schemas";3import { notes } from "./notes";4
5export type DB = DrizzleSqliteDODatabase<typeof schema>;6
7export type Note = typeof notes.$inferSelect;8export type InsertNote = typeof notes.$inferInsert;
And we export all the schemas in a single file.
1export * from "./notes";
Database Operations
Now that we have a schema, let’s create the functions that will interact with our database. These will be the core operations our Durable Object will expose. Each use should have the ability to create a new note, delete a note by ID, get a specific note by ID, and list all notes.
1import { eq } from "drizzle-orm";2import { notes } from "./notes";3import { DB, InsertNote, Note } from "./types";4
5// Create a new note6export async function create(db: DB, note: InsertNote): Promise<Note> {7 const [res] = await db8 .insert(notes)9 .values(note)10 .onConflictDoUpdate({11 target: [notes.id],12 set: note,13 })14 .returning();15
16 return res;17}18
19// Delete a note by ID20export async function del(db: DB, params: { id: string }): Promise<Note> {21 const [note] = await db22 .delete(notes)23 .where(eq(notes.id, params.id))24 .returning();25 return note;26}27
28// Get a note by ID29export async function get(db: DB, params: { id: string }): Promise<Note | null> {30 const [result] = await db31 .select()32 .from(notes)33 .where(eq(notes.id, params.id));34 if (!result) return null;35 return result;36}37
38// List all notes39export async function list(db: DB): Promise<Note[]> {40 const ns = await db41 .select()42 .from(notes)43 return ns;44}
These operations are using fairly standard Drizzle’s query builder API functions, it gives us a clean, fluent interface for building SQL queries.
Implementing the Durable Object
Now let’s look at how we implement our Durable Object. This is where the magic happens, each Durable Object instance will be tied to a specific user and contain their own SQLite database.
1import { DurableObject } from 'cloudflare:workers';2import { Hono } from 'hono'3import { Bindings } from '../bindings';4import { drizzle } from 'drizzle-orm/durable-sqlite';5import * as schema from "./db/schemas";6import * as notes from "./db/index";7
8import { DB } from './db/types';9
10const app = new Hono<{ Bindings: Bindings }>();11
12app.get('/', async (c) => {13 return c.json({ message: "Hello World!" })14});15
16export default {17 async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {18 return app.fetch(request, env, ctx);19 },20};21
22export class DurableDatabase extends DurableObject {23 private db: DB;24
25 constructor(ctx: DurableObjectState, env: Bindings) {26 super(ctx, env);27 // Initialize Drizzle with the Durable Object's storage28 this.db = drizzle(ctx.storage, { schema, logger: true });29 }30
31 async notesCreate(note: Parameters<typeof notes.create>[1]): ReturnType<typeof notes.create> {32 return await notes.create(this.db, note);33 }34
35 async notesGet(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.get> {36 return await notes.get(this.db, params);37 }38
39 async notesList(): ReturnType<typeof notes.list> {40 return await notes.list(this.db);41 }42
43 async notesDel(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.del> {44 return await notes.del(this.db, params);45 }46}
Let’s break down what’s happening here:
-
In the constructor, we initialize Drizzle with
ctx.storage
, which is the Durable Object’s built-in storage that SQLite operates on. This gives us aDrizzleSqliteDODatabase
which is unique per Durable Object. Everytime we do an operation using this object, we are interacting with a single SQLite instance. -
We create methods that map directly to our database operations. These methods will be called from our API routes.
The key insight here is that each instance of our DurableDatabase
class is completely isolated from other instances. When we create a Durable Object for a user, they get their own private database.
Database Migrations
Database migration for Durable Object SQLite can be confusing, but the key thing to understand is that migrations must be run in the constructor of the Durable Object class.
Durable Object constructors run when a request is routed to an instance that isn’t currently active, basically on cold start. When you try to access a specific Durable Object from a Worker using its name/ID, if the Durable Object for that ID isn’t already in memory somewhere in Cloudflare’s edge network, a new instance spins up and the constructor is run once.
After that, the object stays warm for a while (usually minutes of inactivity) before being evicted from memory. If another request comes in later, the object will spin up again and the constructor is called again.
It then makes sense to run migrations in the Durable Object class constructor. When a new request is routed to a specific Durable Objects, the migration files since the last migration will be run in the constructor. This enables doing migrations on potentially millions of Durable Objects, “just-in-time”.
1import { DurableObject } from 'cloudflare:workers';2import { Hono } from 'hono'3import { Bindings } from '../bindings';4import { drizzle } from 'drizzle-orm/durable-sqlite';5import { migrate } from 'drizzle-orm/durable-sqlite/migrator';6import migrations from '../drizzle/migrations';7import * as schema from "./db/schemas";8import * as notes from "./db/index";9
10import { DB } from './db/types';11
12const app = new Hono<{ Bindings: Bindings }>();13
14app.get('/', async (c) => {15 return c.json({ message: "Hello World!" })16});17
18export default {19 async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {20 return app.fetch(request, env, ctx);21 },22};23
24export class DurableDatabase extends DurableObject {25 private db: DB;26
27 constructor(ctx: DurableObjectState, env: Bindings) {28 super(ctx, env);29 // Initialize Drizzle with the Durable Object's storage30 this.db = drizzle(ctx.storage, { schema, logger: true });31
32 // Run migrations before accepting any requests33 ctx.blockConcurrencyWhile(async () => {34 await this._migrate();35 });36 }37
38 async notesCreate(note: Parameters<typeof notes.create>[1]): ReturnType<typeof notes.create> {39 return await notes.create(this.db, note);40 }41
42 async notesGet(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.get> {43 return await notes.get(this.db, params);44 }45
46 async notesList(): ReturnType<typeof notes.list> {47 return await notes.list(this.db);48 }49
50 async notesDel(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.del> {51 return await notes.del(this.db, params);52 }53
54 private async _migrate() {55 await migrate(this.db, migrations);56 }57}
We use blockConcurrencyWhile
to run migrations before accepting any requests. This ensures our database schema is up-to-date.
You’re asking yourself what is the migrations
we import from ../drizzle/migrations
. These are database migration files created by Drizzle Kit. Drizzle Kit is a CLI tool for managing SQL database migrations with Drizzle.
Let’s define our Drizzle config with the durable-sqlite
driver.
1import { defineConfig } from 'drizzle-kit';2
3export default defineConfig({4 out: './drizzle',5 schema: './src/db/schemas.ts',6 dialect: 'sqlite',7 driver: 'durable-sqlite',8});
To create the migration files, it’s necessary to run the Drizzle Kit generate
command:
1drizzle-kit generate
This command will create a new folder drizzle
in our project structure, which will keep track of all our migrations. After every change to our database schema, we need to run the same command to generate migration files.
These migration files need to be uploaded alongside our Worker code, such that they can be run on Durable Object cold-starts. However, wrangler
, the Cloudflare Developer Platform command-line interface, only uploads files it generates when bundling a project. It is necessary to explicitely require migration files to be uploaded alongside the Worker code.
1{2 "name": "durable-objects-database-per-user",3 "compatibility_date": "2024-11-12",4 "workers_dev": true,5 "upload_source_maps": true,6 "observability": {7 "enabled": true8 },9 "main": "./src/index.ts",10 "migrations": [11 {12 "new_sqlite_classes": [13 "DurableDatabase"14 ],15 "tag": "v1"16 }17 ],18 "rules": [19 {20 "type": "Text",21 "globs": [22 "**/*.sql"23 ],24 "fallthrough": true25 }26 ],27 "durable_objects": {28 "bindings": [29 {30 "class_name": "DurableDatabase",31 "name": "DurableDatabase"32 }33 ]34 }35}
The rules
section tells Wrangler to include all .sql
files in our deployment. Without this rule, our SQL migration files wouldn’t be included in the deployment, and our migrations would fail.
Setting Up API Routes
Now let’s set up our API routes to interact with our Durable Objects.
1import { DurableObject } from 'cloudflare:workers';2import { Hono } from 'hono'3import { Bindings } from '../bindings';4import { drizzle } from 'drizzle-orm/durable-sqlite';5import { migrate } from 'drizzle-orm/durable-sqlite/migrator';6import migrations from '../drizzle/migrations';7import * as schema from "./db/schemas";8import * as notes from "./db/index";9
10import { DB } from './db/types';11
12const app = new Hono<{ Bindings: Bindings }>();13
14app.get('/', async (c) => {15 return c.json({ message: "Hello World!" })16});17
18export default {19 async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {20 return app.fetch(request, env, ctx);21 },22};23
24function getDurableDatabaseStub(env: Bindings, userId: string) {25 const doId = env.DurableDatabase.idFromName(userId);26 return env.DurableDatabase.get(doId);27}28
29// Create a note for a user30app.post('/:userId', async (c) => {31 const userId = c.req.param("userId");32 const { text } = await c.req.json();33 const stub = getDurableDatabaseStub(c.env, userId);34 const note = await stub.notesCreate({ text });35 return c.json({ note })36});37
38// List all notes for a user39app.get('/:userId', async (c) => {40 const userId = c.req.param("userId");41 const stub = getDurableDatabaseStub(c.env, userId);42 const notes = await stub.notesList()43 return c.json({ notes })44});45
46// Get a specific note for a user47app.get('/:userId/:noteId', async (c) => {48 const userId = c.req.param("userId");49 const noteId = c.req.param("noteId");50 const stub = getDurableDatabaseStub(c.env, userId);51 const note = await stub.notesGet({ id: noteId });52 if (!note) {53 return c.notFound();54 }55 return c.json({ note })56});57
58// Delete a note for a user59app.delete('/:userId/:noteId', async (c) => {60 const userId = c.req.param("userId");61 const noteId = c.req.param("noteId");62 const stub = getDurableDatabaseStub(c.env, userId);63 const note = await stub.notesDel({ id: noteId });64 return c.json({ note })65});66
67export class DurableDatabase extends DurableObject {68 private db: DB;69
70 constructor(ctx: DurableObjectState, env: Bindings) {71 super(ctx, env);72 // Initialize Drizzle with the Durable Object's storage73 this.db = drizzle(ctx.storage, { schema, logger: true });74
75 // Run migrations before accepting any requests76 ctx.blockConcurrencyWhile(async () => {77 await this._migrate();78 });79 }80
81 async notesCreate(note: Parameters<typeof notes.create>[1]): ReturnType<typeof notes.create> {82 return await notes.create(this.db, note);83 }84
85 async notesGet(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.get> {86 return await notes.get(this.db, params);87 }88
89 async notesList(): ReturnType<typeof notes.list> {90 return await notes.list(this.db);91 }92
93 async notesDel(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.del> {94 return await notes.del(this.db, params);95 }96
97 private async _migrate() {98 await migrate(this.db, migrations);99 }100}
The key pattern in each route is:
- Extract the
userId
from the URL - Get a Durable Object ID using
idFromName(userId)
- Get a stub to the Durable Object using
get(doId)
- Call methods on the stub to interact with the user’s database
A durable object stub is the proxy we use to send requests to a specific Durable Object instance. We get the Durable Object ID from the userId
, and we use it to instanciate a lightweight proxy object (the stub).
1function getDurableDatabaseStub(env: Bindings, userId: string) {2 const doId = env.DurableDatabase.idFromName(userId);3 return env.DurableDatabase.get(doId);4}
With the stub, we can call methods on the Durable Object using JavaScript-native RPC. That’s how we’re able to call the notesCreate
, notesGet
, notesDel
, and notesList
methods in the Durable Object.
This is the core of our “one database per user” pattern. By using idFromName(userId)
, we’re creating a consistent mapping from user IDs to Durable Objects. Each user gets their own Durable Object, and thus their own database.
Testing It Out
Now let’s see how our “one database per user” pattern works in practice. After you have deployed your Worker using wrangler
, you’ll get a URL for your Worker:
1https://durable-objects-database-per-user.account-name.workers.dev
where account-name
is the name of your account.
Here are some example API calls:
Creating a note for user “john”
1curl -X POST https://durable-objects-database-per-user.account-name.workers.dev/john \2 -H "Content-Type: application/json" \3 -d '{"text":"Buy groceries"}'4
5# Response:6# {7# "note": {8# "id": "note_jxqegmbonzvdstpy",9# "text": "Buy groceries",10# "created": "2025-03-23T15:42:18.760Z",11# "updated": "2025-03-23T15:42:18.760Z"12# }13# }
Listing all notes for “john”
1curl https://durable-objects-database-per-user.account-name.workers.dev/john2
3# Response:4# {5# "notes": [6# {7# "id": "note_jxqegmbonzvdstpy",8# "text": "Buy groceries",9# "created": "2025-03-23T15:42:18.760Z",10# "updated": "2025-03-23T15:42:18.760Z"11# }12# ]13# }
Creating a note for user “bob”
1curl -X POST https://durable-objects-database-per-user.account-name.workers.dev/bob \2 -H "Content-Type: application/json" \3 -d '{"text":"Finish the report"}'4
5# Response:6# {7# "note": {8# "id": "note_lnkcpfqyrtzbmags",9# "text": "Finish the report",10# "created": "2025-03-23T15:43:05.120Z",11# "updated": "2025-03-23T15:43:05.120Z"12# }13# }
Listing all notes for “bob”
1curl https://durable-objects-database-per-user.account-name.workers.dev/bob2
3# Response:4# {5# "notes": [6# {7# "id": "note_lnkcpfqyrtzbmags",8# "text": "Finish the report",9# "created": "2025-03-23T15:43:05.120Z",10# "updated": "2025-03-23T15:43:05.120Z"11# }12# ]13# }
The key insight here is that “john” and “bob” have completely separate databases.
Limitations and Considerations
While this pattern is powerful, there are a few things to keep in mind:
- Storage Limits - Each Durable Object has a storage limit (currently 1GB), which could be limiting and push us to further sharding depending on the scale of our app
- Cross-User Operations - Queries across multiple users’ data, for example back-office queries or analytics, require additional work
On Basebrain, I’m going around these with further sharding and by using a different database for analytics, Workers Analytics Engine.