Nov 7, New York: Romaric speaks at SREday NYC: SRE in the era of AI

How to Migrate a Cloudflare Workers App to Kubernetes: A 7-Step Guide for When You Need a Real Database

A practical 7-step guide to migrating a Cloudflare Workers application to Kubernetes in your own AWS, GCP, Azure, or Scaleway account, with a direct mapping for D1, KV, R2, Durable Objects, Queues, and Cron Triggers, plus a no-downtime cutover plan.

Romaric Philogene
CEO & Co-founder
NOV 4, 2026 · 16 MIN
How to Migrate a Cloudflare Workers App to Kubernetes: A 7-Step Guide for When You Need a Real Database

Teams rarely leave Cloudflare Workers because Workers is bad. They leave because their app grew a real database, a long-running process, or a native dependency that a 128 MB V8 isolate was never designed to hold. Migrating a Workers app to Kubernetes is a runtime and state migration: you move the code from workerd to a long-running Node, Bun, or Go container, and you move the data from D1, KV, and Durable Objects to Postgres or MySQL, Redis or Valkey, and a real job queue. The edge layer (DNS, CDN, WAF, Access, R2) stays on Cloudflare.

I have moved workloads between runtimes enough times to know where the time goes. It is almost never the HTTP handler. It is the state, the connection pool, and the headers you forgot Cloudflare was setting for you.

Qovery · Infrastructure control plane
Kubernetes, operated through one governed API
Learn more

Key points:

  • This is a port, so skip the rewrite. A Hono app runs on both workerd and Node.js through @hono/node-server, so the entry point usually ports in a day.
  • The storage mapping is mechanical. D1 becomes managed Postgres or MySQL, KV becomes Redis or Valkey, R2 becomes S3, GCS, Azure Blob, or Scaleway Object Storage (or stays on R2), Queues becomes SQS, Pub/Sub, Service Bus, RabbitMQ, or Redis Streams, and Cron Triggers become Kubernetes CronJobs.
  • Durable Objects set your schedule. Their storage is only reachable through the object's own code, so you write the export path yourself. A Workers app with D1 and KV takes days; one with Durable Objects takes weeks.
  • The number one production trap is connections. Pool size times replica count must stay under Postgres max_connections, which on Amazon RDS defaults to LEAST({DBInstanceClassMemory/9531392}, 5000) (AWS RDS docs).
  • Kubernetes is cheaper only at sustained load. Workers CPU costs $0.072 per CPU-hour; an on-demand m7g.large vCPU costs $0.0408 per hour, so the cluster wins only above roughly 57% average utilization.

Why do teams move a Cloudflare Workers app to Kubernetes?

Teams move a Cloudflare Workers app to Kubernetes for four reasons: they need a relational database with long-lived connections, they exceed the per-request CPU budget of a V8 isolate, they depend on native packages workerd cannot load, or they need VPC-private access and strict data residency. Workers runs code in an isolate with documented CPU, memory, and size ceilings. Kubernetes runs a container whose runtime, resources, and network you control.

Each trigger stands on its own:

  • Relational state beyond D1. A D1 database caps at 10 GB on Workers Paid, a limit Cloudflare says "cannot be further increased", and each database is single-threaded and processes queries one at a time (Cloudflare D1 limits).
  • CPU time per request. Workers Paid defaults to 30 seconds of CPU per HTTP request and can be raised to 5 minutes; the Free plan allows 10 ms (Cloudflare Workers limits).
  • Native or heavy dependencies. The Node.js compatibility layer covers built-ins such as crypto, fs, net, and stream, but modules like child_process, worker_threads, and vm are non-functional stubs, and native addons compiled with node-gyp (sharp, canvas, bcrypt) have no place in a JavaScript and WebAssembly isolate.
  • Compliance and private networking. When an auditor wants the database in a private subnet of your own AWS, GCP, Azure, or Scaleway account, with no public endpoint, you need compute inside that network.

The database constraint shapes everything else. Workers can open a TCP connection, but it lives and dies with one invocation, so each request pays the handshake again. Cloudflare built Hyperdrive precisely because connecting "from stateless environments like Workers" requires "multiple round trips for each Worker invocation". In a container, one pool lives for the life of the process.

Migration triggerWhat Cloudflare Workers enforcesWhat Kubernetes gives you
CPU time per request30 s default, 5 min max on Paid; 10 ms on Free (Cloudflare)No per-request CPU cap; you set CPU requests and limits per container
Memory per instance128 MB per isolate, JS heap plus WebAssembly (Cloudflare)Whatever the node can hold, set per container (512 MiB, 4 GiB, 32 GiB)
Bundle or image size64 MiB uncompressed Worker size (Cloudflare)Container images of any practical size, cached per node
Long-lived TCP database connectionsConnections end with the invocation; Hyperdrive pools them at the edgeIn-process pool held for the life of the pod, plus PgBouncer or RDS Proxy
Outbound connections6 simultaneous connections waiting for headers per invocation (Cloudflare)Limited only by file descriptors and your egress
Native dependenciesNo native addons; several Node modules are stubs (Cloudflare)Any Linux binary, glibc or musl, any language
Background and long-running processesctx.waitUntil(), Queues and Cron with 15 min wall time (Cloudflare)Worker Deployments, Jobs, and CronJobs that run as long as needed
WebSocketsSupported, with long sessions typically coordinated through Durable ObjectsPlain WebSocket servers behind the ingress
VPC-private accessReaching private resources requires tunnels or HyperdrivePods run inside your VPC next to the database
Data residencyGlobal by default, with regional controls per productCluster and data live in the region and account you pick

Do not migrate if your Worker does edge routing, auth checks, redirects, A/B splits, static sites, geo-distributed low-latency reads, or bursty low-volume APIs. Workers is excellent at all of those, and a Kubernetes cluster makes every one of them slower or more expensive. Before you leave the family, also check Cloudflare Containers, available on Workers Paid, which runs container images alongside Workers and covers the native-dependency case without leaving Cloudflare. It does not give you a database inside your own VPC.

My verdict: migrate the stateful, CPU-bound core and keep the edge layer on Cloudflare.

What does each Cloudflare Workers component map to on Kubernetes?

Every Cloudflare Workers primitive has a direct equivalent: D1 maps to managed Postgres or MySQL, KV to Redis or Valkey, R2 to S3-compatible object storage (or it stays on R2), Durable Objects to a single-writer service with Postgres advisory locks, Queues to SQS, Pub/Sub, Service Bus, RabbitMQ, or Redis Streams, and Cron Triggers to Kubernetes CronJobs. Write this mapping down before you touch the code and the migration becomes a mechanical port.

Cloudflare primitiveAWSGCPAzureScalewayWhat changes for you
D1RDS for PostgreSQL or MySQLCloud SQLAzure Database for PostgreSQLManaged Database for PostgreSQLSQLite dialect becomes Postgres or MySQL; you manage a connection pool
Workers KVElastiCache (Redis or Valkey)MemorystoreAzure Cache for RedisManaged RedisStrong consistency in one region instead of eventual consistency at the edge
R2S3, or keep R2Cloud Storage, or keep R2Blob Storage, or keep R2Object Storage, or keep R2Same S3 API; moving off R2 adds egress fees
Durable ObjectsSingle-writer service + Postgres advisory locksSame pattern on Cloud SQLSame pattern on Azure DatabaseSame pattern on Managed DatabaseYou implement single-writer routing and locking yourself
QueuesSQSPub/SubService BusQueues, or RabbitMQ / Redis Streams in-clusterConsumer Deployment; replicas set concurrency; DLQ replaces retries
Cron TriggersKubernetes CronJobKubernetes CronJobKubernetes CronJobKubernetes CronJobYou choose timezone and overlap policy explicitly
Pages and static assetsS3 + CloudFront, or keep CloudflareCloud Storage + CDN, or keep CloudflareBlob + Front Door, or keep CloudflareObject Storage + CloudflareStatic build output leaves the app container
Bindings and secretsEnv vars + Secrets ManagerEnv vars + Secret ManagerEnv vars + Key VaultEnv vars + Secret Managerenv.DB becomes process.env.DATABASE_URL, synced by external-secrets
HyperdrivePgBouncer or RDS ProxyPgBouncerPgBouncerPgBouncerPooling moves next to the database
Analytics Engine, Tail Workers, LogpushPrometheus + Loki, or DatadogPrometheus + Loki, or Grafana CloudPrometheus + Loki, or DatadogPrometheus + Loki, or CockpitLogs go to stdout; you own retention

D1 becomes managed Postgres or MySQL. The gotcha is the dialect: SQLite tolerates dynamic typing and TEXT dates that Postgres rejects, so the schema translation is real work (section 4).

Workers KV becomes Redis or Valkey. KV is eventually consistent: Cloudflare states changes "may take up to 60 seconds or more to be visible in other global network locations" and allows 1 write per second to the same key (How KV works, KV limits). Redis in one region is an upgrade in consistency and write rate, and a downgrade in read latency for users far from that region.

Durable Objects become a single-writer service. A Durable Object is a single-threaded, strongly consistent actor with up to 10 GB of SQLite storage per object and a soft limit of 1,000 requests per second (Durable Objects limits). On Kubernetes you rebuild that guarantee: a Deployment of any size where each object ID is guarded by pg_advisory_lock, which PostgreSQL holds "until explicitly released or the session ends" (PostgreSQL docs), or a single-replica StatefulSet if you prefer in-memory state. This is the hardest part of any Workers migration, and it is why I tell teams to estimate it first.

Cloudflare Queues becomes a consumer Deployment. Queues caps messages at 128 KB, consumer batches at 100 messages, retries at 100, and retention at 14 days (Queues limits). On SQS, Pub/Sub, Service Bus, RabbitMQ, or Redis Streams, replica count becomes your concurrency knob and a dead-letter queue takes over the retry policy.

Cron Triggers become CronJobs. The trap is overlap: concurrencyPolicy defaults to Allow, so a slow run and the next run execute at the same time unless you set Forbid or Replace (Kubernetes CronJob docs). Set .spec.timeZone (stable since Kubernetes v1.27) and startingDeadlineSeconds so missed runs behave predictably.

Bindings become environment variables. env.DB, env.MY_KV, and env.BUCKET turn into connection strings injected from AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or external-secrets. Hyperdrive disappears entirely, because an in-process pool plus PgBouncer or RDS Proxy does the same job next to the database.

Cloudflare DNS, CDN, WAF, Access, Turnstile, and R2 all work in front of a Kubernetes origin. You are moving compute and keeping Cloudflare.

How do I rewrite a Workers fetch handler as a containerized HTTP service?

Replace export default { fetch(request, env, ctx) } with a standard HTTP server listening on a port, swap the env bindings for environment variables, and you have a container. A Hono app usually ports in under a day because Hono ships an official Node.js adapter, @hono/node-server (Node 18.14.1 or later), and Node already exposes fetch (unflagged since v18.0.0) and Web Crypto (unflagged since v19.0.0) as globals (Node.js globals).

Here is a typical Worker reading from KV and D1:

TS|src/index.ts - Cloudflare Workers
export interface Env { DB: D1Database; CACHE: KVNamespace }

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const match = url.pathname.match(/^\/users\/([^/]+)$/);
    if (!match) return new Response("Not found", { status: 404 });

    const cached = await env.CACHE.get(`user:${match[1]}`);
    if (cached) return new Response(cached, { headers: { "content-type": "application/json" } });

    const user = await env.DB.prepare("SELECT id, email FROM users WHERE id = ?").bind(match[1]).first();
    if (!user) return new Response("Not found", { status: 404 });

    ctx.waitUntil(env.CACHE.put(`user:${match[1]}`, JSON.stringify(user), { expirationTtl: 300 }));
    return Response.json(user);
  },
};

And the same route as a Node service on Hono, with a pool, health checks, and SIGTERM draining:

TS|src/server.ts - Node.js container
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Pool } from "pg";
import Redis from "ioredis";

const db = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const cache = new Redis(process.env.REDIS_URL!);
const app = new Hono();
let ready = true;

app.get("/healthz", (c) => c.text("ok"));
app.get("/readyz", (c) => (ready ? c.text("ok") : c.text("draining", 503)));

app.get("/users/:id", async (c) => {
  const id = c.req.param("id");
  const cached = await cache.get(`user:${id}`);
  if (cached) return c.body(cached, 200, { "content-type": "application/json" });

  const { rows } = await db.query("SELECT id, email FROM users WHERE id = $1", [id]);
  if (rows.length === 0) return c.text("Not found", 404);

  await cache.set(`user:${id}`, JSON.stringify(rows[0]), "EX", 300);
  return c.json(rows[0]);
});

const server = serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 8080) });

process.on("SIGTERM", () => {
  ready = false; // readiness fails, Kubernetes stops sending new traffic
  setTimeout(() => server.close(async () => {
    await db.end();
    cache.disconnect();
    process.exit(0);
  }), 5000);
});

Watch the connection math. Pool size multiplied by replica count must stay under the database max_connections. On RDS for PostgreSQL the default is LEAST({DBInstanceClassMemory/9531392}, 5000), and DBInstanceClassMemory excludes memory RDS reserves for itself (AWS RDS docs). A db.t4g.micro has 1 GiB, so its ceiling is below 1,073,741,824 / 9,531,392 = 112 connections. Ten replicas with a pool of 20 want 200 connections, and the HPA scaling to 15 replicas during a spike wants 300. Put PgBouncer or RDS Proxy in front, and keep per-pod pools small.

Replace ctx.waitUntil(). In Workers it keeps the isolate alive after the response. In a container, fire-and-forget work dies on the next rollout, so await it, or push it to a queue consumer Deployment.

Replace the Workers-only APIs. The caches API, HTMLRewriter, env bindings, the request.cf object, and Workers-specific crypto extensions have no Node equivalent. Use Redis for caching, a streaming HTML parser for rewriting, and Cloudflare headers for geo data.

Wire probes and draining. Point the liveness probe at /healthz and the readiness probe at /readyz, and log JSON to stdout. Kubernetes sends SIGTERM, waits a default grace period of 30 seconds, then sends SIGKILL (Kubernetes pod lifecycle). Without the handler above, every rollout drops in-flight requests.

Build a small, non-root image with a multi-stage Dockerfile:

DOCKERFILE
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production PORT=8080
EXPOSE 8080
USER nonroot
CMD ["dist/server.js"]

Size the container on purpose. The isolate model hid sizing from you. On Kubernetes, CPU limits "are enforced by CPU throttling" and memory limits by OOM kills (Kubernetes resource management), so a limit set too low shows up as p95 latency spikes or restarts.

Audit module-scope state. A variable at the top of a module was effectively per-request in a recycled isolate. In a process that lives for days, it becomes a cross-request leak: user data in a global map, a counter that never resets, a cache that grows until OOM.

Keep warm replicas. Cloudflare says an isolate starts "around a hundred times faster than a Node process on a container or virtual machine" (How Workers works). Image pull plus pod scheduling plus Node boot takes seconds, so set a minimum of two replicas on user-facing paths instead of scaling to zero.

Ship faster on infrastructure you control.
Qovery gives your team self-service deployments on your own AWS, GCP, Azure, or Scaleway account - or your existing Kubernetes cluster. Start deploying in under 10 minutes.

How do I migrate data from D1, KV, R2, and Durable Objects without downtime?

Export D1 with wrangler d1 export, translate the SQLite schema to Postgres or MySQL, bulk-load it, verify with row counts and primary-key checksums, then run a dual-write window or a short read-only window while you shift traffic. Durable Objects are the only piece without a bulk export path, so they set the schedule for the whole migration.

D1. Dump the remote database to a SQL file (Wrangler D1 commands):

BASH
npx wrangler d1 export my-db --remote --output=./d1-dump.sql
# schema only: add --no-data   data only: add --no-schema   one table: --table=users

Then fix the SQLite-isms: INTEGER PRIMARY KEY AUTOINCREMENT becomes GENERATED ALWAYS AS IDENTITY, dates stored as TEXT become timestamptz, 0/1 integers become boolean, TEXT UUIDs become uuid, and columns that relied on dynamic typing get a real type. Load with pgloader or a hand-written migration.

Verification gate. Before anyone says "cutover", compare per-table row counts, an MD5 of the sorted primary keys on both sides, and NULL counts per nullable column. If any of the three differ, you are not done.

KV. Enumerate keys with npx wrangler kv key list --binding=CACHE --remote, read values with wrangler kv bulk get (Wrangler KV commands), and backfill Redis or Postgres. Treat most KV namespaces as caches that rewarm, and copy byte-perfectly only the ones that hold source-of-truth data.

R2. R2 "implements the S3 API" at https://<ACCOUNT_ID>.r2.cloudflarestorage.com (R2 S3 API), so rclone sync or aws s3 sync --endpoint-url moves objects to S3, GCS, Azure Blob, or Scaleway Object Storage. Often the cheapest answer is to keep R2: Cloudflare charges no egress for any storage class and $0.015 per GB-month for Standard storage (R2 pricing), and your pods call it with any S3 SDK.

Durable Objects. Storage is private to each object and only reachable through the object's own code. Add an authenticated /export route to each Durable Object class, enumerate the object IDs from your own records (the user, room, or document IDs you derive names from), put the object in read-only mode, dump its state, replay it into Postgres rows, and verify per object. Prove this end to end on production data before you migrate anything else.

Dual-write or read-only window. Choose a read-only window when write volume is low and your SLO tolerates a few minutes of degraded writes. Choose dual-write when writes are continuous, and only if they are idempotent (upserts keyed by a client-generated ID), otherwise retries create duplicates on one side.

Shadow traffic. Replay a sample of production GET requests against the new stack and diff the responses before a real user lands there.

Rollback trigger. Write it down before the cutover: for example, 5xx rate above twice the Workers baseline, or p95 latency 50% above baseline for five minutes, sends traffic straight back to Workers.

How do I run the migrated app on Kubernetes in my own cloud account?

You need six things on the Kubernetes side - a cluster, ingress with TLS, a managed database, secrets management, autoscaling, and CI/CD - and the only real decision is whether you assemble and operate them yourself or let an internal developer platform run them inside your own cloud account. Qovery takes the second path: it installs into your AWS, GCP, Azure, or Scaleway account, or connects to your existing Kubernetes cluster, so the cloud bill, the VPC, and the data stay in your name.

The DIY path in full: an EKS, GKE, AKS, or Scaleway Kapsule cluster, VPC and subnets, ingress-nginx or a cloud load balancer controller, cert-manager, external-secrets, HPA, Helm or Kustomize overlays per environment, GitHub Actions for build and rollout, and a named owner for upgrades. Kubernetes ships roughly three minor releases a year and supports each for about 14 months (Kubernetes releases, patch release policy), so upgrades come around every four months. The CNCF's latest annual survey notes that "cultural and operational shifts" have overtaken security as the top hurdle, and that service mesh use fell from 50% to 42% "due to operational overhead concerns" (CNCF, April 2025).

The monthly floor, at 730 hours on AWS us-east-1 list prices:

  • EKS control plane: $0.10 per cluster per hour, so $73 (AWS). GKE charges the same $0.10 per cluster per hour (Google Cloud).
  • Two on-demand m7g.large nodes (2 vCPU, 8 GiB) at $0.0816 per hour: $119.14 (AWS EC2 pricing).
  • RDS for PostgreSQL db.t4g.small, Single-AZ, at $0.032 per hour: $23.36 (AWS RDS pricing).

That is about $215 a month before load balancers, NAT gateways, storage, and backups. Compare Workers Paid: $5 minimum, 10 million requests and 30 million CPU-ms included, then $0.30 per million requests and $0.02 per million CPU-ms (Cloudflare Workers pricing). An API serving 20 million requests a month at 5 ms of CPU each costs $9.40 on Workers.

The CPU math is what decides the heavy cases. $0.02 per million CPU-ms is $0.072 per CPU-hour; an m7g.large vCPU costs $0.0408 per hour. Kubernetes wins on compute only when your nodes average above roughly 57% utilization, or when reserved and spot pricing push the node price down. Teams that migrate to save money on a low-traffic API are usually disappointed. Teams that migrate for the database, the memory, and the native dependencies, and then run a busy service on well-packed nodes, come out ahead.

What Qovery handles concretely: git-push deployments, ingress and TLS, managed databases backed by your cloud provider's own service, per-environment RBAC, managed cluster upgrades, preview environments per pull request, and auto-stop for non-production environments. What Qovery does not do: it does not migrate your D1 data or your Durable Object state. Section 4 is still your job; Qovery runs the stack you migrate onto.

The alternatives deserve a fair hearing. Fly.io, Render, and Railway are simpler if you accept running in their account. Google Cloud Run suits stateless containers and keeps scale-to-zero. AWS App Runner stopped accepting new customers on April 30, 2026, and AWS points to ECS Express Mode instead (AWS). Northflank and Porter also offer BYOC. Raw Terraform plus Helm is the right call if you already have a platform team.

Stay on Cloudflare WorkersDIY Kubernetes (Terraform + Helm)Qovery on your cloud account (BYOC or existing cluster)Managed PaaS (Fly.io, Render, Railway)Serverless containers (Cloud Run)
Who owns the cloud account and billCloudflareYouYou (AWS, GCP, Azure, Scaleway, or your cluster)The PaaS vendorYou (GCP)
Realistic time to first production deployAlready thereWeeksHours to daysHoursHours to days
Who performs cluster upgradesNot applicableYour team, about every 4 monthsQoveryThe vendorGoogle (no cluster)
Managed relational databaseD1 (10 GB cap), or external via HyperdriveRDS, Cloud SQL, Azure Database, Scaleway Managed DBSame cloud-native databases, provisioned for youVendor-managed PostgresCloud SQL
Preview environment per pull requestPreview URLs for Workers versionsBuild it yourselfBuilt inRender and Railway built in; scripted on Fly.ioBuild it yourself
Data residency and VPC-private accessEdge-global by defaultFull controlFull control, inside your VPCVendor regions, vendor networkYour GCP VPC
Scale to zeroYes, by designPossible with extra toolingAuto-stop for non-productionVaries by vendorYes
Long-running processes and native dependenciesLimited (15 min wall time, no native addons)YesYesYesYes, with request-timeout limits
Ongoing ops burdenLowestHighestLowLowLow

Keep the Cloudflare proxy orange-clouded in front of the Kubernetes ingress. The traffic shift then becomes an origin change your customers never see.

What is the 7-step Cloudflare Workers to Kubernetes migration checklist?

Run the migration in seven ordered steps: inventory, target design, code port, target environment, data migration, progressive traffic shift, and decommission. Do not start step 5 until the Durable Objects export path from step 4 works end to end on real data. A Workers app with D1 and KV typically takes days; one with Durable Objects takes weeks.

  1. Inventory. List every binding, route, cron trigger, queue, secret, and outbound third-party call in wrangler.toml or wrangler.jsonc. Record current p95 latency, request volume, and error rate as your baseline.
  2. Choose the target. Pick the cloud provider and regions (AWS, GCP, Azure, Scaleway, or an existing cluster), the database engine, cache, object storage, and what deliberately stays on Cloudflare: DNS, CDN, WAF, Access, R2.
  3. Port the code. HTTP server, environment variables, driver-based database access, health checks, Dockerfile. Run it locally with docker compose against a local Postgres until the test suite passes.
  4. Stand up the target environment. Cluster or Qovery environment, managed database, secrets, ingress with a staging hostname and real TLS, and the Durable Objects export endpoint proven against production data.
  5. Migrate data. Schema translation, bulk load, row-count and checksum verification, then dual-write or a read-only window.
  6. Shift traffic. Point the Cloudflare proxy at the new origin and move 1%, 10%, 50%, then 100%, watching p95 latency, 5xx rate, database connection count, and pod restarts at each step.
  7. Decommission. Remove Workers routes, rotate and revoke API tokens, archive the D1 database, and keep the Workers deployment switchable for two weeks as a rollback.

The things that break silently:

  • Client IP. The socket now shows a Cloudflare address. Read CF-Connecting-IP, which Cloudflare sends with "the client IP address connecting to Cloudflare to the origin web server" (Cloudflare HTTP headers), and configure your ingress to trust only Cloudflare ranges.
  • X-Forwarded-For chains. Cloudflare appends to an existing header, so rate limiters keyed on the first entry can be spoofed.
  • Geo data. request.cf is gone; use the CF-IPCountry header, which carries a two-letter country code, XX for unknown, and T1 for Tor.
  • IP allowlists. Third-party APIs that allowlisted Cloudflare now see your NAT gateway egress IP. Get it allowlisted before step 6.
  • Timeouts and body sizes. Cloudflare returns a 524 if your origin does not respond within 125 seconds (Cloudflare 524), and request bodies cap at 100 MB on Free and Pro, 200 MB on Business (Cloudflare limits). Your ingress has its own defaults (ingress-nginx accepts 1 MB bodies unless you change proxy-body-size).
  • Fresh-isolate assumptions. Any code that relied on a clean global scope per request.

How do I run this migration with Qovery from Claude Code or Cursor?

Install the Qovery Agent Skills once, then describe the migration in plain language to your coding agent: it containerizes the ported Workers app, provisions the managed database in your own AWS, GCP, Azure, or Scaleway account, and deploys it, asking you to approve each infrastructure action. The Qovery MCP Server is read-only by default, so the agent can inspect before it changes anything.

Install globally for every compatible agent (Claude Code, Cursor, Codex, OpenCode, Gemini CLI):

BASH
curl -fsSL https://skill.qovery.com/install.sh | bash

Scope the install to the current project only:

BASH
curl -fsSL https://skill.qovery.com/install.sh | bash -s -- --project

Connect the Qovery MCP Server to Claude Code over OAuth, read-only by default, so the agent can inspect environments and logs without changing anything:

BASH
claude mcp add --transport http qovery https://mcp.qovery.com/mcp --callback-port 4242

Enable write access only when you want the agent to apply changes. Write access must also be turned on in the Qovery Console:

BASH
claude mcp add --transport http qovery "https://mcp.qovery.com/mcp?read_write=true" --callback-port 4242

qovery-onboard connects your cloud account, qovery-deploy handles the first deployment, qovery-preview creates per-PR environments, qovery-troubleshoot diagnoses a crashlooping pod, qovery-optimize and qovery-speedup tune cost and build time afterwards, and qovery-terraform covers infrastructure as code. Then ask, for example: "containerize this Hono app that used to run on Cloudflare Workers and deploy it to my GCP account with Qovery", "create a managed Postgres and wire DATABASE_URL into the app", or "open a preview environment for this pull request". Setup details are in the AI agent quickstart and the Claude Code integration page.

Frequently asked questions
Can I keep Cloudflare DNS, CDN, WAF, Access, and R2 after moving my app to Kubernetes?

Yes. Point the orange-clouded DNS record at your Kubernetes ingress and every Cloudflare edge product keeps working in front of the new origin. R2 stays reachable from pods through its S3-compatible endpoint, with no egress charges. Restrict the ingress to Cloudflare IP ranges so nobody bypasses the WAF.

What replaces Cloudflare Durable Objects on Kubernetes?

A single-writer service backed by Postgres advisory locks, where each object ID maps to a pg_advisory_lock key and its state lives in Postgres rows. That reproduces the one-writer-per-object guarantee of a Durable Object. You have to write the export route inside each Durable Object class yourself, which is why this piece drives the migration timeline.

How do I migrate a Cloudflare D1 database to PostgreSQL?

Run npx wrangler d1 export my-db --remote --output=./d1-dump.sql, translate the SQLite schema, and load it with pgloader or a migration script. The usual fixes are AUTOINCREMENT to identity columns, TEXT dates to timestamptz, and 0/1 integers to booleans. Verify row counts and primary-key checksums before you shift any traffic.

What replaces Workers KV, Cloudflare Queues, and Cron Triggers on Kubernetes?

Redis or Valkey replaces KV, a consumer Deployment on SQS, Pub/Sub, Service Bus, RabbitMQ, or Redis Streams replaces Queues, and a Kubernetes CronJob replaces each Cron Trigger. Redis gives you strong consistency where KV can take 60 seconds or more to propagate globally. Set concurrencyPolicy: Forbid on CronJobs, because the default Allow lets runs overlap.

Will my app get slower after leaving the Cloudflare edge network?

Reads served from the edge can get slower for users far from your region, while database-heavy requests usually get faster because the app sits next to Postgres in the same VPC. Keeping Cloudflare's CDN in front preserves cached responses at the edge. Plan for cold starts too: pods take seconds to start where isolates take milliseconds, so keep at least two warm replicas.

Is Kubernetes more expensive than Cloudflare Workers for the same traffic?

For low-traffic APIs, yes: a minimal EKS setup with two m7g.large nodes and a small RDS instance costs about $215 a month, against $9.40 on Workers for 20 million light requests. Workers CPU costs $0.072 per CPU-hour against $0.0408 for an m7g.large vCPU, so Kubernetes only wins at sustained utilization above roughly 57%. Reserved or spot nodes move that break-even lower.

How long does a Cloudflare Workers to Kubernetes migration take?

A Workers app using only D1 and KV typically takes days, and one with Durable Objects takes weeks. The HTTP port is the quick part, since a Hono app moves to Node with @hono/node-server in about a day. The data migration and the 1% to 100% traffic shift take the rest of the time.

Do I need a DevOps engineer to run my app on Kubernetes after migrating off Cloudflare Workers?

Not if a platform runs the cluster for you. Qovery operates Kubernetes inside your own AWS, GCP, Azure, or Scaleway account, including the managed database, TLS, and cluster upgrades, which arrive about every four months. Without a platform, someone on your team owns that work, and the CNCF reports that cultural and operational shifts have overtaken security as the top cloud native hurdle.

Romaric Philogene
About the author
Romaric Philogene

Romaric founded Qovery to make Kubernetes accessible to every engineering team. He writes about platform strategy, developer experience, and the future of cloud infrastructure.

Next step

Ship faster on infrastructure you control.

Qovery gives your team self-service deployments on your own AWS, GCP, Azure, or Scaleway account - or your existing Kubernetes cluster. Start deploying in under 10 minutes.