DevOps ·

Zero-Downtime Deployments with Cloudflare Workers

A practical guide to zero-downtime deployments on Cloudflare Workers, covering the V8 isolate deploy model, in-flight request handling, database migration strategies using expand-contract, Durable Object migrations, canary deployments with custom routing, feature flags at the edge, rollback strategies, and deploy health monitoring.

Zero-Downtime Deployments with Cloudflare Workers

Zero-downtime deployment gets discussed as if it is a hard problem everywhere. On Cloudflare Workers, most of the traditional complexity disappears. No blue-green environment switching, no load balancer reconfiguration, no draining connection pools. The platform handles the mechanics. The challenge shifts to the parts it cannot handle for you: database schema changes, stateful Durable Object migrations, and knowing whether a deploy actually succeeded.

This article focuses on the deployment and operations side of Workers. If you want a foundation on the runtime model, storage primitives, and production patterns, the Edge Computing with Cloudflare Workers guide covers that ground. Here, the assumption is you already understand Workers and want to ship to production without breaking live traffic.

How Workers Deployments Actually Work

Understanding the deploy mechanism removes a lot of worry about whether zero-downtime is achievable. It is the default behavior, not something you configure.

When you run wrangler deploy, Cloudflare compiles your Worker and distributes it to every edge location in their network. The propagation is global and typically completes within 15-30 seconds. During that window, some edge nodes are serving the old version and some are serving the new one. Both versions are valid and both are handling requests.

Critically, in-flight requests are not interrupted. A request that started executing on the old version completes on the old version. New requests start on whatever version is active at their edge node. There is no moment where a request arrives mid-swap and gets a broken response.

This is the V8 isolate model working in your favor. Isolates are not processes. Deploying a new version does not kill running isolates. The old version’s isolates run to completion; new requests start on new isolates running the new version.

The practical implication: your application code itself can be deployed without ceremony. The caution applies to what your application code depends on, primarily database schema.

Database Migrations: The Real Risk

The most common source of deploy-related downtime on Workers is not the Worker itself. It is database schema changes that break the running version of your code before the new version is fully propagated.

The failure mode looks like this: you add a NOT NULL column to a table without a default, deploy your schema migration, and then deploy your Worker. During the 15-30 second propagation window, the old Worker code is running against the new schema. Inserts fail. Users get errors.

The solution is the expand-contract pattern. It requires discipline but it is straightforward once you internalize it.

Expand-Contract Pattern

Every breaking schema change is split into at least two deploys:

Phase 1: Expand. Add the new column as nullable (or with a default). Deploy the schema change. Both the old and new application code can function correctly with the expanded schema. The old code ignores the new column; the new code reads and writes it.

Phase 2: Contract. After the new application version is fully deployed and confirmed healthy, add constraints (NOT NULL, indexes, foreign keys) and remove deprecated columns. This second migration runs against a codebase that already handles the new schema exclusively.

A concrete example with D1:

-- Phase 1: Expand
-- Safe to run before deploying new application code
ALTER TABLE users ADD COLUMN display_name TEXT;

-- Backfill existing rows (run as a separate step or via a Worker migration job)
UPDATE users SET display_name = email WHERE display_name IS NULL;
// New application code handles both states during the transition window
async function getUser(env: Env, userId: string): Promise<User> {
  const result = await env.DB.prepare(
    "SELECT id, email, display_name FROM users WHERE id = ? LIMIT 1"
  )
    .bind(userId)
    .first<{ id: string; email: string; display_name: string | null }>();

  if (!result) throw new Error("User not found");

  return {
    id: result.id,
    email: result.email,
    // Fall back to email until all rows are backfilled
    displayName: result.display_name ?? result.email,
  };
}
-- Phase 2: Contract
-- Only safe after new application code is fully deployed
-- and backfill is complete
ALTER TABLE users ALTER COLUMN display_name TEXT NOT NULL;

-- Remove old column (if you renamed or replaced something)
-- ALTER TABLE users DROP COLUMN old_field;

The same logic applies when you are removing a column. First, stop writing to it in the application. Deploy. Then drop the column in a subsequent migration. If you drop the column while the old code is still live, any query that selects it fails.

Running Migrations Safely

For D1, migrations run via wrangler:

# Apply to production
wrangler d1 migrations apply your-database --remote --env production

# Always preview first
wrangler d1 migrations apply your-database --remote --env production --dry-run

Keep migrations in a migrations/ directory with sequential numbering. Wrangler tracks which migrations have been applied in a d1_migrations table it manages automatically.

migrations/
  0001_initial_schema.sql
  0002_add_display_name_nullable.sql
  0003_backfill_display_name.sql
  0004_make_display_name_not_null.sql

For Hyperdrive-connected Postgres, the same pattern applies. Run prisma migrate deploy or your migration tool before deploying the Worker. The expand phase gives you the safety window.

Durable Object Migrations

Durable Objects require a different approach because they carry persistent state. You cannot just swap the class definition and expect existing DO instances to work seamlessly.

Cloudflare requires you to declare class renames and deletions explicitly in wrangler.toml via the migrations array. Without this, Cloudflare refuses to deploy if you rename or remove a DO class.

# wrangler.toml

[[durable_objects.bindings]]
name = "SESSION"
class_name = "UserSession"

[[migrations]]
tag = "v1"
new_classes = ["UserSession"]

[[migrations]]
tag = "v2"
renamed_classes = [{ from = "UserSession", to = "UserSessionV2" }]

[[migrations]]
tag = "v3"
deleted_classes = ["UserSessionV2"]

Each migration tag is applied once per DO namespace globally. Cloudflare tracks which migration has been applied to each namespace.

Migrating DO State

When you need to change what a Durable Object stores (add fields, restructure storage), the safest approach is lazy migration: the new class version reads the old format and writes the new format. The first time an existing instance is accessed after deploy, it migrates its own state.

interface SessionDataV1 {
  userId: string;
  createdAt: string;
}

interface SessionDataV2 {
  userId: string;
  createdAt: string;
  // New field added in v2
  lastActiveAt: string;
}

export class UserSession implements DurableObject {
  private state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const raw = await this.state.storage.get<SessionDataV1 | SessionDataV2>("session");

    // Lazy migration: upgrade v1 data to v2 on first access
    const session = await this.migrateIfNeeded(raw);

    // ... handle request
    return Response.json(session);
  }

  private async migrateIfNeeded(
    data: SessionDataV1 | SessionDataV2 | undefined
  ): Promise<SessionDataV2> {
    if (!data) {
      // New instance, no migration needed
      return { userId: "", createdAt: new Date().toISOString(), lastActiveAt: new Date().toISOString() };
    }

    // Check if migration is needed (v1 lacks lastActiveAt)
    if (!("lastActiveAt" in data)) {
      const migrated: SessionDataV2 = {
        ...data,
        lastActiveAt: data.createdAt, // reasonable default
      };
      // Persist the migrated state
      await this.state.storage.put("session", migrated);
      return migrated;
    }

    return data as SessionDataV2;
  }
}

This pattern handles the transition gracefully. Old instances upgrade themselves on first contact; new instances are created with the new format from the start. No global migration job needed, no downtime.

Rollback Strategy

Workers has a straightforward rollback mechanism: every deploy is versioned and you can revert to a previous version from the dashboard or via the API.

# List recent deployments
wrangler deployments list

# Roll back to a specific deployment
wrangler rollback <deployment-id>

The rollback takes the same 15-30 seconds to propagate as a forward deploy. The important constraint: rolling back the Worker does not roll back your database migrations. This is why the expand-contract pattern matters. If your schema migrations are backward-compatible (the expand phase), rolling back the Worker is safe. The old code can run against the expanded schema.

If you have already run a contract migration (dropped a column, added a NOT NULL constraint), rolling back the Worker version may not be enough. Keep your contract migrations separate and delayed so you have a rollback window. A common rule: run the expand migration on deploy day, wait 24-48 hours to confirm stability, then run the contract migration as a separate step.

For Durable Objects, rolling back the class code does not revert instance state. If your lazy migration has already upgraded stored data, the rolled-back code needs to handle both formats. Design your DO state migrations to be backward-compatible during the rollback window.

Canary Deployments with Custom Routing

Workers does not have built-in canary deploy support. You get full rollout or rollback. But you can implement canary logic yourself using a routing Worker and Workers KV for the feature flag percentage.

The idea: deploy your new version as a separate Worker, then use a routing layer to split traffic between old and new based on a percentage stored in KV.

// canary-router/src/index.ts
// This Worker sits in front of both versions and splits traffic

interface CanaryConfig {
  percentage: number; // 0-100, what percentage goes to new version
  enabled: boolean;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const config = await getCanaryConfig(env, ctx);

    if (config.enabled && shouldRouteToCanary(request, config.percentage)) {
      // Route to new version Worker via service binding
      return env.WORKER_V2.fetch(request);
    }

    // Route to stable version
    return env.WORKER_V1.fetch(request);
  },
};

async function getCanaryConfig(env: Env, ctx: ExecutionContext): Promise<CanaryConfig> {
  // Cache in module scope to avoid KV reads on every request
  if (cachedConfig && Date.now() - configFetchedAt < 10_000) {
    return cachedConfig;
  }

  const raw = await env.KV.get("canary-config", { type: "json" });
  cachedConfig = (raw as CanaryConfig) ?? { percentage: 0, enabled: false };
  configFetchedAt = Date.now();
  return cachedConfig;
}

function shouldRouteToCanary(request: Request, percentage: number): boolean {
  // Use a consistent hash of a stable identifier (IP, user ID from cookie)
  // so the same user always hits the same version during a canary window
  const identifier =
    request.headers.get("CF-Connecting-IP") ??
    request.headers.get("Cookie")?.match(/session_id=([^;]+)/)?.[1] ??
    Math.random().toString();

  const hash = simpleHash(identifier);
  return (hash % 100) < percentage;
}

function simpleHash(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
  }
  return hash;
}

let cachedConfig: CanaryConfig | null = null;
let configFetchedAt = 0;

The wrangler.toml for the router binds both Workers as services:

# canary-router/wrangler.toml
name = "canary-router"
main = "src/index.ts"
compatibility_date = "2024-09-23"

kv_namespaces = [
  { binding = "KV", id = "your-kv-namespace-id" }
]

[[services]]
binding = "WORKER_V1"
service = "your-worker"
entrypoint = "default"

[[services]]
binding = "WORKER_V2"
service = "your-worker-v2"
entrypoint = "default"

To roll out: update the canary-config key in KV to increase the percentage. Start at 1-5%, watch error rates, then increase to 10%, 25%, 50%, 100%.

# Start canary at 5%
wrangler kv key put "canary-config" '{"percentage":5,"enabled":true}' \
  --namespace-id your-kv-namespace-id --remote

# Increase after validation
wrangler kv key put "canary-config" '{"percentage":25,"enabled":true}' \
  --namespace-id your-kv-namespace-id --remote

# Full rollout
wrangler kv key put "canary-config" '{"percentage":100,"enabled":true}' \
  --namespace-id your-kv-namespace-id --remote

This approach has a cost: you are maintaining two deployed Workers simultaneously. That is fine for a canary window of hours or a day. Clean up the old version and the routing layer once the rollout is confirmed.

Feature Flags at the Edge

For teams that want more granular control without the routing-Worker overhead, feature flags stored in KV cover most canary and gradual-rollout scenarios within a single Worker.

// lib/flags.ts

interface FeatureFlags {
  newCheckoutFlow: boolean;
  aiSearchEnabled: boolean;
  // percentage-based flags use a number (0-100)
  newDashboardRollout: number;
}

const defaultFlags: FeatureFlags = {
  newCheckoutFlow: false,
  aiSearchEnabled: false,
  newDashboardRollout: 0,
};

let flagCache: FeatureFlags | null = null;
let flagCacheFetchedAt = 0;
const FLAG_CACHE_TTL_MS = 15_000;

export async function getFlags(env: Env): Promise<FeatureFlags> {
  const now = Date.now();
  if (flagCache && now - flagCacheFetchedAt < FLAG_CACHE_TTL_MS) {
    return flagCache;
  }
  const raw = await env.KV.get("feature-flags", { type: "json" });
  flagCache = { ...defaultFlags, ...(raw as Partial<FeatureFlags> ?? {}) };
  flagCacheFetchedAt = now;
  return flagCache;
}

export function isEnabledForUser(rolloutPercentage: number, userId: string): boolean {
  const hash = simpleHash(userId);
  return (hash % 100) < rolloutPercentage;
}

Using the flag in a handler:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const flags = await getFlags(env);
    const userId = getUserIdFromRequest(request);

    if (url.pathname === "/dashboard") {
      if (
        flags.newDashboardRollout > 0 &&
        userId &&
        isEnabledForUser(flags.newDashboardRollout, userId)
      ) {
        return handleNewDashboard(request, env);
      }
      return handleDashboard(request, env);
    }

    // ...
  },
};

The KV propagation delay (up to 60 seconds) means flag changes are not instantaneous. For most gradual rollouts this is acceptable. If you need sub-second flag propagation, use a Durable Object as the flag store instead. The tradeoff is higher latency for reads from edge locations far from the DO’s region.

Monitoring Deploy Health

The Workers platform does not automatically tell you if a deploy is bad. You need observability to catch regressions quickly.

Cloudflare Workers Logpush can stream request logs to your observability stack (Datadog, Grafana Cloud, Axiom, etc.). Set up a dashboard with:

  • Error rate (5xx responses) by Worker version
  • P50/P95/P99 response time
  • CPU time distribution (high CPU often signals an infinite loop or pathological input)

For error tracking within the Worker, use ctx.waitUntil to ship errors to an external service without blocking the response:

// middleware/logging.ts

interface ErrorEvent {
  message: string;
  stack?: string;
  requestId: string;
  url: string;
  method: string;
  timestamp: string;
  workerVersion: string;
}

export async function withErrorTracking(
  request: Request,
  env: Env,
  ctx: ExecutionContext,
  handler: () => Promise<Response>
): Promise<Response> {
  const requestId = crypto.randomUUID();

  try {
    const response = await handler();

    // Log non-2xx responses without blocking
    if (!response.ok) {
      ctx.waitUntil(
        logEvent(env, {
          type: "http_error",
          requestId,
          status: response.status,
          url: request.url,
          method: request.method,
          timestamp: new Date().toISOString(),
        })
      );
    }

    return new Response(response.body, {
      ...response,
      headers: new Headers({ ...Object.fromEntries(response.headers), "X-Request-Id": requestId }),
    });
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));

    ctx.waitUntil(
      logEvent(env, {
        type: "unhandled_exception",
        message: error.message,
        stack: error.stack,
        requestId,
        url: request.url,
        method: request.method,
        timestamp: new Date().toISOString(),
        workerVersion: env.WORKER_VERSION ?? "unknown",
      })
    );

    return new Response("Internal Server Error", { status: 500 });
  }
}

async function logEvent(env: Env, event: object): Promise<void> {
  await fetch(env.LOG_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.LOG_API_KEY}` },
    body: JSON.stringify(event),
  });
}

Inject WORKER_VERSION as a secret or environment variable in wrangler.toml to distinguish which deploy generated an error:

[env.production.vars]
WORKER_VERSION = "2026-03-06-abc123"

Set this to a git SHA or a timestamp at deploy time. It makes post-deploy error spikes immediately attributable to a specific deploy.

Comparison: Workers vs. Container-Based Deployments

For context, here is how Workers deployments differ from blue-green and rolling deploys on container platforms like ECS, Kubernetes, or Fly.io.

Blue-green on containers: You maintain two identical environments. You cut traffic from blue to green at the load balancer level. In-flight requests on blue drain before it is decommissioned. This works but requires double the infrastructure during the switch, and you need a coordinated cutover step. Rollback is a load balancer change, which is fast but still has a propagation window.

Rolling deploys on Kubernetes: Pods running the old version are replaced with new pods gradually, one or a few at a time. The scheduler ensures minimum availability during the rollout. This handles in-flight requests at the pod level, but rollout takes time proportional to cluster size. A bad deploy does not surface until enough pods have rolled over to show a signal.

Workers: Deploys are atomic and globally propagated within seconds. There are no pods, no load balancer configuration, no environment to maintain. The runtime handles in-flight requests by isolate version, not by instance. The surface area for deploy failure is smaller, but the tooling for staged rollouts (canary, feature flags) is something you build yourself, not something the platform provides.

This is not an argument that Workers is always better. For workloads that require long-lived processes, heavy CPU, or TCP connections to databases without an HTTP proxy layer, containers are the right choice. But for stateless API logic, edge routing, and thin application layers backed by cloud-native storage, Workers removes an entire class of operational complexity that container-based deployments require you to manage.

At Let’s Build Solutions, we reach for Workers when the use case fits, precisely because the deploy story is simpler. Fewer moving parts means fewer things to break at 2am.

Closing Thoughts

The zero-downtime story on Workers is genuinely simpler than on most platforms. The runtime handles the hard parts. Your job is to make sure the things it cannot handle for you (database schema, Durable Object state) are backward-compatible across the deploy window.

The summary for a production Workers deploy process: run expand-phase schema migrations before deploying application code, design Durable Object state migrations to be lazy and backward-compatible, keep a rollback window before running contract-phase schema changes, and set up error rate monitoring with Worker version tagging so you can correlate spikes to specific deploys. With those in place, a Workers deploy is about as low-risk as a deploy gets.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.