Using Drizzle Kit with a Local Cloudflare D1 Database

Use Drizzle Kit with Cloudflare D1 locally. Resolve SQLite paths, handle bindings, and simplify migrations with @deox/drizzle-d1-utils.

Cloudflare D1 is a great fit for full-stack Workers projects. Drizzle ORM is a great fit for type-safe SQL in TypeScript. On paper they belong together — but when you try to wire up Drizzle Kit for migrations against a local D1 database, you quickly discover there's a gap nobody has cleanly filled.

This article covers what that gap is, why it exists, and how @deox/drizzle-d1-utils closes it.


How Drizzle Kit Supports D1 — and Where It Stops

Drizzle Kit has a d1-http driver. You configure it in drizzle.config.ts like this:

// drizzle.config.ts
import type { Config } from 'drizzle-kit';

export default {
  dialect: 'sqlite',
  driver: 'd1-http',
  dbCredentials: {
    databaseId: 'your-database-id',
    accountId: process.env.CF_ACCOUNT_ID!,
    token: process.env.CF_API_TOKEN!,
  },
  schema: './src/schema.ts',
  out: './drizzle',
} satisfies Config;

This works perfectly for remote — it hits the Cloudflare D1 HTTP API and runs your migrations against the live database. But for local development, this is the wrong tool. You don't want to run migrations against production every time you iterate on a schema.

The correct approach for local development is to use the SQLite file that Wrangler persists on disk when you run your Worker locally. Drizzle Kit can talk to a plain SQLite file using dbCredentials.url. But Drizzle Kit has no idea where Wrangler put that file — and Wrangler doesn't tell you in any structured way.


The Naive Solution: Find the SQLite File Yourself

Wrangler persists local D1 state somewhere inside .wrangler/state/. The exact path looks like:

.wrangler/state/v3/d1/miniflare-D1DatabaseObject/<hash>.sqlite

The hash in the filename is derived from the binding's database ID, so it isn't predictable. The folder can contain multiple .sqlite files if you have multiple D1 bindings.

The obvious workaround is to search for it:

// drizzle.config.ts
import type { Config } from 'drizzle-kit';
import { globSync } from 'glob';

const [sqliteFile] = globSync('.wrangler/**/*.sqlite');

export default {
  dialect: 'sqlite',
  dbCredentials: { url: `file:${sqliteFile}` },
  schema: './src/schema.ts',
  out: './drizzle',
} satisfies Config;

This works — until it doesn't. The moment you have more than one D1 binding, globSync returns multiple files and you're picking the first one arbitrarily. You have no way to know which .sqlite file belongs to which binding without reading the Wrangler config and correlating database IDs to filenames yourself.

Projects with even modest complexity — a primary DB plus a queue, a cache, or an analytics store — hit this immediately. And the solution doesn't scale: you'd need a different drizzle.config.ts per binding, all with hardcoded paths, all fragile to Wrangler version bumps that change the state directory structure.


The Right Approach: Read the Wrangler Config

The information needed to do this correctly is all in wrangler.toml (or wrangler.json). Each D1 binding has:

  • A binding name (e.g. DB)
  • A database_id (used to derive the SQLite filename)
  • Optionally a preview_database_id
  • Optionally a database_name

Wrangler uses the database_id to construct the SQLite filename deterministically. So if you parse the Wrangler config, extract the right binding by name, and reconstruct the path using the same logic Wrangler uses, you get the exact file — no guessing, no globbing.

That's the core of what @deox/drizzle-d1-utils does. The getD1BindingInfo utility reads your Wrangler config, resolves the correct binding (by name, or defaulting to the first one found), applies the active environment if specified, and returns the fully resolved SQLite path alongside the database metadata.


What the Package Provides

The public API is a single function:

function drizzleD1Config(config: DrizzleKitConfig, options?: DrizzleD1Options): Config

You pass your base Drizzle Kit config (schema path, migrations output directory, etc.) and a set of D1-specific options. The function resolves everything and returns a complete Config ready to export.

Local Mode

// drizzle.config.ts
import { drizzleD1Config } from '@deox/drizzle-d1-utils';

export default drizzleD1Config(
  { schema: './src/schema.ts', out: './drizzle' },
  { binding: 'DB' },
);

That's it. The function reads wrangler.toml, finds the DB binding, derives the SQLite file path from the database_id, and produces:

{
  dialect: 'sqlite',
  dbCredentials: { url: 'file:.wrangler/state/v3/d1/miniflare-D1DatabaseObject/<hash>.sqlite' },
  schema: './src/schema.ts',
  out: './drizzle',
}

Remote Mode

// drizzle.config.ts
import { drizzleD1Config } from '@deox/drizzle-d1-utils';

export default drizzleD1Config(
  { schema: './src/schema.ts', out: './drizzle' },
  {
    binding: 'DB',
    remote: true,
    accountId: process.env.CF_ACCOUNT_ID,
    apiToken: process.env.CF_API_TOKEN,
  },
);

In remote mode the function switches to driver: 'd1-http' and forwards the credentials. The remote flag can also be set on the binding in wrangler.toml, in which case you don't need to pass it here at all — the function inherits it from the binding config.

Preview Mode

Wrangler supports a preview_database_id on D1 bindings — a separate database used for preview deployments. Passing preview: true targets that database instead of the production one, in either local or remote mode.

// drizzle.config.ts
import { drizzleD1Config } from '@deox/drizzle-d1-utils';

export default drizzleD1Config(
  { schema: './src/schema.ts', out: './drizzle' },
  { binding: 'DB', preview: true },
);

Environments

If your wrangler.toml uses [env.staging] or similar environment blocks, pass the environment name and the correct binding config for that environment is used:

// drizzle.config.ts
import { drizzleD1Config } from '@deox/drizzle-d1-utils';

export default drizzleD1Config(
  { schema: './src/schema.ts', out: './drizzle' },
  { binding: 'DB', environment: 'staging' },
);

Handling the Missing SQLite File

There's one more edge case that trips people up early in a project: the local SQLite file doesn't exist until you've run your Worker locally at least once (or explicitly initialised the D1 database with Wrangler).

When the file is missing, the package detects it and prompts you interactively:

[!] SQLite file for D1 binding 'DB' does not exist.
[!] The file can be created by executing the following command:
    wrangler d1 execute MyDatabase --command='SELECT 1' --local
[!] Run this command to create it? (y/N)

Answering y runs the Wrangler command in-process and then verifies the file was created before continuing. This turns a confusing silent failure into a self-healing setup step.


Installation

npm install -D @deox/drizzle-d1-utils

Peer dependencies:

npm install -D drizzle-kit wrangler

For local mode, a SQLite driver is also required:

npm install -D better-sqlite3
# or
npm install -D @libsql/client

The remote driver (d1-http) is bundled with Drizzle Kit and needs no additional installation.


Why Not Just Use an Environment Variable?

A common workaround is to set a DATABASE_URL environment variable to the SQLite file path and read it in drizzle.config.ts. This works, but it moves the problem rather than solving it — you still need to find the right path, you still need to handle multiple bindings, and you need to keep the variable in sync when Wrangler's state directory changes (which it has between major versions).

Tying the config to the Wrangler config file means the source of truth is always wrangler.toml, which is already the canonical definition of your bindings. When you rename a binding, change a database_id, or add an environment, the config adapts automatically.


Summary

Drizzle Kit's d1-http driver covers the remote case well. The local case — pointing Drizzle Kit at the SQLite file Wrangler persists on disk — has no official solution, and the naive glob-based workaround breaks as soon as a project has more than one D1 binding.

@deox/drizzle-d1-utils solves this by reading the Wrangler config directly, resolving the correct binding by name, and constructing the exact SQLite path the same way Wrangler does. It handles local, remote, and preview modes through a single unified drizzleD1Config function, and it covers the missing-file edge case with an interactive setup prompt.

The result is a drizzle.config.ts that stays clean across environments, doesn't require manual path management, and works correctly regardless of how many D1 bindings your project has.

Documentation Links

Copyright (c):
fineshopdesign.com

About the author

Deø Kumar
Lost in the echoes of another realm.

Post a Comment

To avoid SPAM, all comments will be moderated before being displayed.