· cloudflare / aws / workers
How to Migrate from AWS to Cloudflare in 2026: The Complete Playbook
Move Lambda, S3, API Gateway, RDS, and SQS to Cloudflare Workers, R2, D1, and Queues — step-by-step with real wrangler.toml snippets, before/after code diffs, and an honest when-not-to section.
By Ethan
2,395 words · 12 min read
If you run a JS/TypeScript API on Lambda + S3, the Cloudflare migration math has flipped in your favor. D1 went GA in April 2024 and closed the database gap. Hyperdrive means you don’t even have to move your Postgres. Zero egress fees from R2 cut storage bills by 60–80% for high-egress workloads. For I/O-heavy APIs, Workers bills CPU time only — not wall-clock — which is typically 50–80% cheaper per invocation than Lambda.
This is the playbook. Not a balanced vendor comparison — that article already exists. This one tells you how to do the migration.
Who this is for
Developers running Lambda, S3, CloudFront, API Gateway, or DynamoDB with JavaScript or TypeScript workloads. If you’re on Python, Java, Go, or Ruby runtimes — or you need more than 5 minutes of compute per invocation — jump to When NOT to migrate first.
The service map
Every AWS service you’re replacing has a direct Cloudflare counterpart, and all of them are GA as of 2026:
| AWS | Cloudflare | Status |
|---|---|---|
| Lambda | Workers | GA |
| S3 | R2 | GA — $0.015/GB, zero egress |
| CloudFront + API Gateway | Workers routing / Pages | GA |
| RDS (PostgreSQL/MySQL) | D1 (SQLite) or Hyperdrive (keep RDS) | GA since Apr 2024 |
| DynamoDB | Workers KV + Durable Objects | GA |
| SQS | Cloudflare Queues | GA |
| Kinesis | Cloudflare Pipelines | Open beta — not production-ready |
| Cognito | Cloudflare Access + Workers JWT | GA |
| SageMaker / Bedrock | Workers AI | GA, 50+ models |
One flag: Pipelines (the Kinesis replacement) is still in open beta as of May 2026. Don’t route production event streams through it yet.
Sign up for Cloudflare Workers to start — R2, D1, Queues, and Workers KV are all enabled from the same dashboard.
Compute: Lambda → Workers
Workers is a V8-based JavaScript/TypeScript/WebAssembly serverless runtime deployed across 330+ cities in 100+ countries. No cold starts — V8 isolates, not containers.
The code migration is a syntax change more than a logic change:
Before (Lambda/Node.js):
exports.handler = async (event, context) => {
const body = JSON.parse(event.body || '{}');
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hello', input: body })
};
};
After (Cloudflare Worker — ESM):
export default {
async fetch(request, env, ctx) {
const body = await request.json();
return Response.json({ message: 'Hello', input: body });
}
};
Four things changed:
- Workers use the Fetch API (
Request/Response) — the same standard your browser uses, not a Lambda event envelope. - Workers are ESM modules, not CommonJS.
envreplacesprocess.env. Bindings (R2, D1, KV) live onenvdirectly — no SDK clients to instantiate.- No
context.callbackWaitsForEmptyEventLoopgymnastics.
Deploy with a single command: wrangler deploy. Compare that to SAM, CDK, or Terraform on AWS.
Full-stack wrangler.toml
This configuration wires up all the primitives you’ll use in a typical migration:
name = "my-app"
main = "src/index.ts"
compatibility_date = "2026-05-23"
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-app-assets"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id-here"
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
[[queues.producers]]
binding = "JOB_QUEUE"
queue = "my-app-jobs"
[[queues.consumers]]
queue = "my-app-jobs"
max_batch_size = 10
max_batch_timeout = 30
max_retries = 3
dead_letter_queue = "my-app-jobs-dlq"
Pricing: Lambda vs Workers
| Dimension | AWS Lambda | Cloudflare Workers |
|---|---|---|
| Requests (paid) | $0.20/million | $0.30/million |
| Compute billing | Duration × memory (wall-clock) | CPU time only |
| Data transfer out | $0.09/GB | $0 |
| Minimum monthly | $0 pay-as-you-go | $5/month (Workers Paid) |
The request price looks worse for Workers, but the compute billing model reverses that math. If your Lambda function runs for 200ms but only uses 15ms of CPU (the rest is waiting on I/O), Lambda charges you for 200ms. Workers charges you for 15ms. For I/O-heavy APIs that’s where the 50–80% savings come from.
See current pricing at Cloudflare Workers Pricing and AWS Lambda Pricing.
Workers limits to know upfront
- Runtimes: JS/TS/WASM only. No Python, Java, Go, or Ruby.
- Memory: 128MB maximum. Lambda scales to 10GB.
- CPU: 30 seconds default limit, 5 minutes maximum on Paid plan.
- No persistent filesystem: No
/tmp. Everything goes to R2, KV, or D1. - No Docker images: You can’t lift-and-shift a containerized Lambda.
Source: Workers Limits
Storage: S3 → R2
R2 is S3-compatible object storage with zero egress fees. Most S3 SDKs work with minimal reconfiguration — you’re changing the endpoint, not the API.
Reconfiguring the S3 client
// Before: AWS S3
import { S3Client } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
// After: R2 (same SDK, different endpoint)
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
}
});
R2 supports all the operations you likely use: PutObject, GetObject, HeadObject, DeleteObject, ListObjectsV2, CopyObject, and multipart uploads. What it doesn’t support: ACLs, bucket policies, AWS KMS server-side encryption, and object tagging.
Incremental migration with Sippy
You don’t have to copy everything at once. Cloudflare’s Sippy feature migrates objects on-demand: when a request hits R2 for an object that doesn’t exist yet, R2 fetches it from S3 and stores it. After your traffic flips to R2, objects migrate gradually as they’re accessed. No big-bang copy job, no downtime.
Pricing: S3 vs R2
| Dimension | AWS S3 (Standard) | Cloudflare R2 (Standard) |
|---|---|---|
| Storage | $0.023/GB-month | $0.015/GB-month |
| Egress | $0.09/GB (after 100GB free) | $0 always |
| Free tier | 5GB, 20K GETs, 2K PUTs | 10GB, 10M GETs, 1M PUTs |
At 100TB stored + 10TB monthly egress:
- S3: ~$1,500 storage + ~$921 egress = ~$2,421/month
- R2: ~$1,500 storage + $0 egress = ~$1,500/month
At 1PB with high egress, that math compounds dramatically. See current pricing at R2 Pricing and R2 vs S3 Calculator.
CDN and routing: CloudFront + API Gateway → Workers
Workers sit in front of your traffic globally — there’s no separate distribution to configure. A single Worker handles routing, caching, A/B testing, auth, and response transformation. You don’t pay for a CDN layer separately.
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith('/static/')) {
return env.ASSETS.fetch(request);
}
if (url.pathname.startsWith('/api/')) {
return handleApi(request, env);
}
return env.ASSETS.get('index.html').then(obj =>
new Response(obj.body, { headers: { 'Content-Type': 'text/html' } })
);
}
};
API Gateway elimination
API Gateway charges $1.00–$3.50/million requests on top of Lambda costs. With Workers, your API routing is built into the Worker — no separate gateway charge.
| Service | Cost |
|---|---|
| AWS API Gateway (HTTP) | $1.00/million requests |
| AWS API Gateway (REST) | $3.50/million requests |
| Cloudflare Workers routing | Included in Workers request cost |
Source: Amazon API Gateway Pricing
Security is another line item you lose. Cloudflare includes unlimited DDoS protection and WAF on all plans. AWS Shield Advanced costs $3,000/month plus usage fees.
Database: RDS and DynamoDB → D1, KV, and Hyperdrive
You have two strategies here. Choose based on your schema complexity.
Strategy A: Migrate to D1 (small-to-medium apps)
D1 is Cloudflare’s managed serverless SQLite database. GA since April 2024, with global read replication in beta since April 2025 at no extra cost.
# Create a database
wrangler d1 create my-app-db
# Apply a schema migration
wrangler d1 execute my-app-db --file ./schema.sql
// Query from a Worker
const result = await env.DB.prepare(
'SELECT * FROM users WHERE id = ?'
).bind(userId).first();
PostgreSQL → D1 migration gotchas:
INTEGER PRIMARY KEYinstead ofSERIAL/BIGSERIAL- No native UUID type — store as TEXT
- No
ARRAYorJSONB— use JSON functions - No stored procedures or PL/pgSQL
- Limited ALTER TABLE (no DROP COLUMN in SQLite < 3.35)
D1 handles up to 10GB per database. For larger datasets, run D1 as your primary store with R2 for blob data.
Pricing (Paid plan): 25 billion rows read/month + 50 million rows written/month included. Source: D1 Overview
Strategy B: Keep RDS, use Hyperdrive (complex schemas)
If your schema uses PostgreSQL extensions, stored procedures, row-level security, or complex multi-CTE joins — don’t migrate the database. Use Hyperdrive instead. It lets Workers connect to your existing RDS instance with connection pooling and automatic read query caching at the edge. No code changes to your database layer.
# wrangler.toml
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "your-hyperdrive-id"
import { Client } from 'pg';
export default {
async fetch(request, env) {
const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
await client.connect();
const result = await client.query('SELECT * FROM users WHERE id = $1', [id]);
await client.end();
return Response.json(result.rows[0]);
}
};
Hyperdrive handles connection pool management, caches read queries at the edge, and works with Drizzle, Prisma, Knex, and TypeORM. Pricing: free for Workers Paid plan. Source: Hyperdrive Overview
DynamoDB simple KV → Workers KV
For DynamoDB tables used as key-value stores (lookup by primary key, no complex queries):
// Before: DynamoDB GetItem
const item = await dynamoDB.getItem({
TableName: 'sessions',
Key: { sessionId: { S: sessionId } }
}).promise();
// After: Workers KV
const session = await env.SESSIONS.get(sessionId, { type: 'json' });
Workers KV: 10M reads/month + 1M writes/month included in Paid plan. Globally replicated, eventually consistent. For strong consistency, use Durable Objects instead.
Queues: SQS → Cloudflare Queues
Cloudflare Queues gives you guaranteed at-least-once delivery, batching, retries, delays, and dead-letter queues — integrated directly into Workers.
Producer:
export default {
async fetch(request, env) {
const job = await request.json();
await env.JOB_QUEUE.send(job);
return new Response('Queued', { status: 202 });
}
};
Consumer:
export default {
async queue(batch, env) {
for (const message of batch.messages) {
await processJob(message.body, env);
message.ack();
}
}
};
Configure the queue in wrangler.toml:
[[queues.producers]]
binding = "JOB_QUEUE"
queue = "my-app-jobs"
[[queues.consumers]]
queue = "my-app-jobs"
max_batch_size = 10
max_batch_timeout = 30
max_retries = 3
dead_letter_queue = "my-app-jobs-dlq"
Pricing:
- SQS Standard: $0.40/million messages
- SQS FIFO: $0.50/million messages
- Cloudflare Queues: included in Workers Paid ($5/month base)
Source: Cloudflare Queues
Auth: Cognito → Cloudflare Access or Workers JWT
Two options depending on your use case.
Option A: Cloudflare Access (internal tools and B2B)
Cloudflare Access replaces Cognito + API Gateway authorizers for protecting internal apps and admin interfaces. Configure it in the dashboard — no code changes. It integrates with your existing identity provider: Google, GitHub, Okta, SAML, or OIDC. You can even keep Cognito as the IdP and use Access as the policy enforcement layer:
# Cloudflare dashboard → Zero Trust → Access → Applications
# Add application → Self-hosted
# Set Access policy: "allow @company.com Google accounts to reach /admin"
Source: Cognito OIDC integration with Cloudflare Access
Option B: Workers JWT verification (public-facing APIs)
For apps that need Cognito’s user pools (sign-up, sign-in, password reset), the path is either a third-party auth service (Auth0, Clerk, Supabase Auth) or direct JWT verification in a Worker:
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://your-auth-provider.com/.well-known/jwks.json')
);
export default {
async fetch(request, env) {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
try {
const { payload } = await jwtVerify(token, JWKS);
return handleRequest(request, env, payload);
} catch {
return new Response('Unauthorized', { status: 401 });
}
}
};
When NOT to migrate
This section matters. Cloudflare is not the right answer for every team.
You’re on a non-JS runtime. Python ML workloads, Java microservices, Go services — Lambda supports all of these. Workers only runs JavaScript, TypeScript, and WebAssembly. This is the #1 blocker for most teams that stay on AWS.
You need long-running compute. Video encoding, large model inference, ETL jobs — Lambda supports up to 15 minutes of wall time. Workers maxes out at 5 minutes CPU on the Paid plan (30 seconds on the default limit). If your Lambda functions routinely hit the 10–15 minute range, don’t migrate the compute.
You’re deep in the AWS data ecosystem. Step Functions, Glue, Athena, EMR, Redshift — Cloudflare has no equivalents. Hyperdrive connects your RDS, but the surrounding data platform is thin.
Complex PostgreSQL schemas. D1 is SQLite. PostgreSQL extensions (PostGIS, pg_vector, tsvector full-text search), row-level security, and complex stored procedures don’t port cleanly. Use the Hyperdrive bridge strategy instead of a full D1 migration.
You need data residency in specific AWS regions. Cloudflare operates globally with fewer compliance certifications for regulated industries requiring jurisdiction-specific data residency.
High-memory batch jobs. Workers cap at 128MB. Lambda scales to 10GB.
Production Kinesis replacement. Cloudflare Pipelines is still in open beta as of May 2026. For production event streaming, use Workers + Queues + R2 with custom batching, or Hyperdrive to connect Kafka.
Cost comparison and conclusion
The teams that save the most on this migration share a pattern: high egress from S3, API Gateway + Lambda on every request, and I/O-heavy TypeScript code. Baselime — migrated in 3 months, team of 3 engineers, strangler-fig pattern — cut their cloud bill from $708K to $118K/year (83% reduction).
The numbers at scale (current as of 2026-05):
| Scenario | AWS Monthly (est.) | Cloudflare Monthly (est.) |
|---|---|---|
| 100M API requests | ~$220 (Lambda + APIGW) | ~$45 (Workers) |
| 1TB stored + 10TB egress | ~$2,421 (S3) | ~$1,500 (R2) |
| 100K SQS messages/day | ~$1.20 | $0 (included) |
| DDoS + WAF | $3,000+ (Shield Advanced) | $0 (included) |
Prices change. Link to the calculators:
- Cloudflare Workers Pricing
- R2 Pricing | R2 vs S3 Calculator
- AWS Lambda Pricing
- AWS S3 Pricing
- Amazon API Gateway Pricing
The migration pays off fastest when your spend is weighted toward egress and API Gateway. If you’re running Python or Go, or you have a deep RDS schema you can’t simplify, Cloudflare is a partial migration (Workers for the edge logic, Hyperdrive for the database) rather than a full switch. Run the numbers on your actual bill before you commit.
Sign up for Cloudflare Workers and R2 to start — you can run the free tier in parallel with AWS until you’re ready to cut over.