How to Manage Database Schema Changes and Migrations in Production with Drizzle ORM and Cloudflare D1

You can manage database schema changes and migrations in production by combining Drizzle ORM with Cloudflare D1, using a version-controlled workflow that generates incremental SQL files locally and applies them automatically via Wrangler in your CI/CD pipeline.

Managing database schema changes and migrations in production requires a deterministic approach to prevent data loss and ensure consistency across environments. The ifindev/fullstack-next-cloudflare repository demonstrates a production-ready strategy using Drizzle ORM to define schemas in TypeScript and Cloudflare D1 to execute versioned migrations. This setup ensures every schema change is type-safe, auditable, and deployable through automated pipelines.

The Six-Step Production Migration Workflow

The repository implements a deterministic process for evolving your database schema safely from development to production:

  1. Update TypeScript Schema Definitions – Modify the sqliteTable definitions in your module schema files (such as src/modules/todos/schemas/todo.schema.ts). The central export in src/db/schema.ts aggregates all tables, providing a single source of truth for the Drizzle ORM toolkit.

  2. Generate SQL Migration Files – Run pnpm run db:generate to invoke drizzle-kit, which compares your current schema against the database state and creates an incremental SQL file under src/drizzle/. For descriptive naming, use pnpm run db:generate:named "add_user_preferences".

  3. Validate Changes Locally – Execute pnpm run db:migrate:local to apply pending migrations to your local D1 database file (.wrangler/*.sqlite). The local configuration in drizzle.local.config.ts directs this operation to your development environment.

  4. Test Functionality – Launch the application with pnpm dev or pnpm dev:cf and verify that the schema changes work correctly through the UI or API endpoints.

  5. Deploy via CI/CD – Push your changes to trigger the GitHub Actions workflow in .github/workflows/deploy.yml. The pipeline executes pnpm run db:migrate:prod, which runs wrangler d1 migrations apply next-cf-app --remote to apply only pending migrations to the production database.

  6. Automated Schema Synchronization – The deployment scripts ensure db:migrate:prod runs before the Worker upload, guaranteeing the production schema matches the application code before traffic hits the new version.

Local Development and Testing Configuration

Local development uses a separate configuration to avoid impacting production data. The drizzle.local.config.ts file resolves the local SQLite database path, typically pointing to .wrangler/*.sqlite.

When you run pnpm run db:migrate:local, Wrangler applies migrations to this local file, allowing rapid iteration without cloud resources. This local-first approach ensures you catch schema errors before they reach production.

Production Deployment Architecture

In production, drizzle.config.ts contains the credentials and connection details for the remote Cloudflare D1 instance. The db:migrate:prod script executes:

wrangler d1 migrations apply next-cf-app --remote

This command tracks applied migration IDs directly within the D1 database, ensuring idempotent execution. If the CI/CD pipeline runs multiple times, only new migrations execute, preventing duplicate schema changes or partial application states.

Practical Example: Adding a Column to the Todos Table

Consider adding a priority_level column to track task urgency in the existing todos table.

Step 1: Modify the Schema

Edit src/modules/todos/schemas/todo.schema.ts to include the new column:

// src/modules/todos/schemas/todo.schema.ts
export const todos = sqliteTable("todos", {
  // … existing columns …
  priority: text("priority")
    .$type<TodoPriorityType>()
    .notNull()
    .default(TodoPriority.MEDIUM),

  // NEW column
  priorityLevel: integer("priority_level")
    .notNull()
    .default(0),   // 0 = low, 1 = medium, 2 = high
});

Step 2: Generate the Migration

pnpm run db:generate:named "add_priority_level_to_todos"

This creates src/drizzle/0001_add_priority_level_to_todos.sql containing:

ALTER TABLE `todos` ADD `priority_level` integer NOT NULL DEFAULT 0;

Step 3: Test Locally

pnpm run db:migrate:local
pnpm run dev

Verify the column appears in your local database and functions correctly with the application logic.

Step 4: Deploy to Production

git add .
git commit -m "feat: add priority_level to todos"
git push origin main

The CI/CD pipeline automatically runs pnpm run db:migrate:prod, applying the ALTER TABLE statement to the production D1 database before deploying the Worker code.

Key Files in the Migration System

Understanding these critical files helps you debug and extend the migration workflow:

Summary

Managing database schema changes and migrations in production with this stack relies on deterministic, version-controlled SQL files and environment-specific configurations. Key takeaways include:

  • Modify TypeScript schemas in module files (e.g., src/modules/todos/schemas/todo.schema.ts) and export them via src/db/schema.ts for type safety.
  • Generate migrations using pnpm run db:generate to create auditable SQL files in src/drizzle/.
  • Test changes locally with pnpm run db:migrate:local using the configuration in drizzle.local.config.ts.
  • Deploy to production via CI/CD executing wrangler d1 migrations apply next-cf-app --remote through the db:migrate:prod script.
  • Rely on D1's built-in migration tracking to ensure idempotent, ordered schema updates across all environments.

Frequently Asked Questions

How do I generate a named migration for a specific schema change?

Run pnpm run db:generate:named "descriptive_name" after modifying your TypeScript schema. This creates a timestamped SQL file in src/drizzle/ with a human-readable name, making it easier to identify the purpose of each migration in version control history.

What happens if a production migration fails during CI/CD?

The wrangler d1 migrations apply command runs each migration in a transaction. If a statement fails, the transaction rolls back, leaving the database in its previous state. The CI/CD pipeline will fail before deploying the Worker, preventing application code from running against an incomplete schema.

Can I run migrations against a preview or staging environment?

Yes. You can configure additional scripts in package.json that target specific D1 databases using Wrangler's --env flag. By creating separate configurations for each environment, you can apply the same migration SQL files to preview databases before executing them in production.

How does the system prevent the same migration from running twice?

Cloudflare D1 maintains an internal tracking table that records applied migration filenames. When you run wrangler d1 migrations apply, the system compares the SQL files in your src/drizzle/ directory against this record, executing only those not yet applied. This idempotent approach ensures safe re-runs of deployment scripts without duplicate schema changes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →