Running Payload entirely on Cloudflare means swapping a database connection string and a disk-based upload folder for two native bindings: D1 for the database and R2 for file storage. This guide covers the full setup, from creating the Cloudflare resources to wiring the bindings into a Payload config that keeps working across local development, CLI migrations, and a deployed Worker. The part most guides skip is getting remote: true and remoteBindings configured correctly, so a migration that reports success actually writes to the real database instead of a local Miniflare simulation.
I set this up while building a personal app for tourist registration and tax filing. The project was small enough that keeping the whole stack on Cloudflare made sense: one dashboard, one platform, no separate database or storage bill to manage. The part that took the most trial and error involved keeping Cloudflare bindings available in three different contexts at once: the deployed Worker, local Next.js development, and Payload's own CLI commands for migrations. Getting that wrong lets Payload report a clean migration while the remote database stays empty. This guide walks through the setup that avoids that failure mode.
Tested with payload@3.88.0, @payloadcms/db-d1-sqlite@3.88.0, @payloadcms/storage-r2@3.88.0, and the current Wrangler CLI. Last updated August 13, 2026.
This guide shows how to replace a traditional database server and local upload directory with Cloudflare infrastructure:
Cloudflare D1 provides a managed, serverless SQLite database for Payload documents.
Cloudflare R2 provides S3-compatible object storage for Payload uploads.
Cloudflare Workers runs the deployed application and receives D1 and R2 through native bindings.
Wrangler creates, configures, migrates, and verifies the Cloudflare resources.
The examples use Payload CMS, Next.js, OpenNext, pnpm, and a private R2 bucket. Adapt the names to your application.
1. Understand the architecture
With a conventional deployment, Payload often connects to PostgreSQL or MongoDB over a URL and writes uploaded files to a persistent disk or an S3-compatible service. The Cloudflare-native alternative uses bindings instead:
Bindings are capabilities supplied to the Worker at runtime. The application does not need a D1 connection string or R2 access keys when it runs inside Cloudflare.
Use this architecture when:
the application can use SQLite semantics;
the expected workload fits D1's limits and single-database write model;
uploads should survive stateless Worker deployments;
keeping the database, object storage, and application on Cloudflare is useful.
Before choosing D1, review its current limits. D1 is not a drop-in replacement for every PostgreSQL or MongoDB workload. For a similar production setup on a different cloud, see Payload CMS on Azure: Production Architecture Guide.
If Payload doesn't run on Cloudflare Workers
D1 is the part of this stack that decides whether the architecture fits at all. R2 is portable: its S3-compatible API works from any host, including a Payload app deployed on Vercel. D1 does not offer that flexibility. Payload's official D1 adapter requires a native Cloudflare Worker binding, and Vercel cannot supply one. Calling D1 through its REST API instead of the binding is possible but unsuitable as Payload's primary database interface.
Option
When to use
Trade-off
Keep Payload on Vercel
You want to stay on Vercel
Use PostgreSQL, such as Neon, with a private Cloudflare R2 bucket instead of D1
Move Payload to Cloudflare Workers
You want D1 and R2 together
Requires deploying Payload itself to Workers, which the rest of this guide covers
Build a custom D1 proxy/adapter
You need D1 specifically but can't leave Vercel
Technically possible, adds real complexity, not recommended
For a Vercel-hosted Payload app, Neon Postgres paired with a private Cloudflare R2 bucket covers the same object-storage benefit without the Worker-binding requirement.
Cost considerations
Running Payload on Workers means the account needs the Workers Paid plan, which starts at $5/month and includes 10 million requests and 30 million CPU milliseconds per month, along with a 10 MiB compressed bundle limit instead of the free plan's 3 MiB. Usage beyond those allowances costs $0.30 per million additional requests and $0.02 per million additional CPU milliseconds, and static asset requests don't count against the request quota. A small Payload application typically stays close to the $5 base, plus whatever D1 and R2 usage runs past their own included allowances. See Cloudflare's official Workers pricing and Worker limits for current figures.
2. Prerequisites
You need:
a Cloudflare account;
Node.js and pnpm;
an existing Payload 3 project;
an upload-enabled Payload collection such as media;
a Worker-compatible deployment, such as OpenNext for a Next.js application.
Install Wrangler locally so the project controls its CLI version:
Location hints include weur, eeur, apac, oc, wnam, and enam. If legal or compliance requirements demand that data remain in the European Union, consider --jurisdiction eu instead of a location hint. Confirm the current behavior in the d1 create reference.
Wrangler prints a UUID. Save it for the next step:
text
database_id = <D1_DATABASE_UUID>
Do not create a second database if a previous attempt already created the intended exact-name resource. List first, then reuse its ID.
pnpm exec wrangler r2 bucket info my-payload-app-private
A new R2 bucket is private unless you explicitly enable an r2.dev URL or attach a public custom domain. For protected Payload uploads, leave the bucket private and let Payload enforce collection access control.
Wrangler does not need S3 access keys for these commands; S3 credentials only come into play when an external S3-compatible client accesses R2 directly. See Cloudflare's R2 CLI guide. If you ever need a Next.js route that talks to R2 directly instead of through Payload's storage adapter, How to Upload Files to Cloudflare R2 in a Next.js App covers that pattern.
The remote: true properties matter for CLI processes that use Wrangler's platform proxy. Without them, a Payload migration can report success while writing only to .wrangler/state, leaving the remote D1 database empty.
The Payload configuration below deliberately disables remote bindings during normal local development and enables them when NODE_ENV=production. A deployed Worker always receives its real production bindings.
9. Generate Cloudflare binding types
Generate a TypeScript interface from the Wrangler configuration:
Payload automatically adds file metadata fields and upload operations to an upload-enabled collection. See Payload uploads.
Do not set disablePayloadAccessControl: true for private files. Keeping the default behavior lets requests pass through Payload's read access control instead of exposing direct public R2 URLs.
11. Configure Payload for D1 and R2
The important configuration challenge is obtaining Cloudflare bindings in three contexts:
a deployed Worker;
local Next.js development;
Payload CLI commands such as migrate and generate:types.
Use getCloudflareContext() inside the deployed OpenNext application and Wrangler's getPlatformProxy() for local development and Payload CLI processes:
The keys must exactly match registered Payload collection slugs. Payload's D1 adapter is documented under SQLite: D1 Database, and storage behavior is covered by Storage Adapters. If R2 does not fit your needs and you want a comparable setup with an object storage provider outside Cloudflare, Backblaze B2 Direct Uploads With Payload CMS Jobs walks through that adapter.
12. Create Payload migrations
After defining the collections, create a migration:
sh
pnpm payload migrate:create initial
Commit the generated migration files. Payload migrations are application code and should be reviewed like any other schema change.
For later schema changes:
sh
pnpm payload migrate:create describe_the_change
Avoid Wrangler's SQL migration generator for a Payload-managed schema, and let Payload generate and record its own migrations through @payloadcms/db-d1-sqlite instead.
13. Apply migrations locally
Normal local development uses Wrangler's local D1 simulation under .wrangler/state:
sh
pnpm payload migrate
pnpm dev
Keep .wrangler/ out of Git:
gitignore
.wrangler/
Local and remote D1 are separate databases. A successful local migration says nothing about the remote schema.
14. Apply migrations remotely
Run the Payload CLI with NODE_ENV=production. That makes getPlatformProxy() enable the configured remote bindings:
Always take a backup or confirm D1 Time Travel coverage before applying a destructive production migration.
15. Verify the remote database
Check Payload's migration record:
sh
pnpm exec wrangler d1 execute D1 --remote \
--command"SELECT * FROM payload_migrations ORDER BY id"
List application tables:
sh
pnpm exec wrangler d1 execute D1 --remote \
--command"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
The output should include payload_migrations and tables derived from your Payload collections.
Cloudflare's D1 documentation also recommends explicit --local and --remote verification because they target different databases. See D1 getting started.
16. Verify R2 through Payload
Check the bucket from Wrangler:
sh
pnpm exec wrangler r2 bucket info my-payload-app-private
Then perform an application-level smoke test:
Start or deploy the Payload application.
Sign in to Payload Admin.
Upload a small permitted file to the media collection.
Confirm the Payload document contains the expected filename and metadata.
Confirm the R2 bucket's object count increases.
Download the file through Payload while authenticated.
Confirm an unauthenticated request is rejected when the collection is private.
Delete the test document and confirm the R2 object is removed.
This checklist verifies the full chain: Payload's access control, the R2 adapter, the Worker binding, and object deletion, confirming more than bucket existence alone. If you also serve uploads through a CDN in front of R2, cache headers need separate handling; Cloudflare CDN Cache Headers for Self-Hosted S3 Guide covers the Next.js and Payload specific fix for that.
17. Configure runtime secrets
The Cloudflare API token is a provisioning credential. The deployed application does not need it to use D1 or R2 bindings.
Add only application runtime secrets to the Worker:
sh
pnpm exec wrangler secret put PAYLOAD_SECRET
For CI/CD, store CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in the CI provider's encrypted secret store. Never commit them.
18. Recommended verification commands
Run these before deployment:
sh
pnpm exec wrangler whoami
pnpm exec wrangler d1 list
pnpm exec wrangler r2 bucket list
pnpm exec wrangler types --env-interface CloudflareEnv
pnpm exec tsc --noEmit
pnpm run test:int
pnpm run build:cloudflare
After deployment, repeat the remote D1 query and perform one authenticated upload/download/delete smoke test.
19. Common failures
Invalid access token [code: 9109]
The credential is malformed, expired, revoked, or is not a Cloudflare API token. Create a new API token and replace CLOUDFLARE_API_TOKEN. An R2 S3 secret access key is not interchangeable with a Cloudflare account API token.
Authentication works, but resource creation is forbidden
The token is valid but lacks D1 Edit or Workers R2 Storage Write, or it is restricted to a different account. Correct the token scope instead of granting unrelated permissions.
Payload says the migration succeeded, but remote D1 is empty
This almost always means the migration used local Miniflare storage.
Check all three conditions:
D1 has "remote": true in wrangler.jsonc.
getPlatformProxy() receives remoteBindings: true in production.
The migration runs with NODE_ENV=production.
Then query with an explicit --remote flag. Do not trust migration logs alone.
Files are written to local disk instead of R2
Confirm that:
the collection has upload: true or an upload options object;
its slug appears in r2Storage({ collections: ... });
cloudflare.env.R2 exists in the generated binding types;
the R2 plugin is enabled in the environment being tested.
Private files return 403 or 404
Verify the collection's read access rule and that the request has a valid Payload session. Private R2 files are normally served through Payload so collection access control remains active.
A resource already exists
List existing D1 databases and R2 buckets, then reuse the exact intended resource. Avoid adding suffixes automatically: doing so can silently connect production to a new empty database or bucket.
Local development unexpectedly touches production
Ensure local calls to getPlatformProxy() use remoteBindings: false. Use separate Cloudflare environments and resource names for staging if developers need remote development.
wrangler deploy rejects the build for exceeding the size limit
Cloudflare's free plan caps a compressed Worker bundle at 3 MiB. The Workers Paid plan raises that ceiling to 10 MiB. Payload, Next.js, and dependencies such as XLSX processing, XML validation, or image processing add up quickly, and a real Payload application can land past the free-plan limit without doing anything unusual. Check the actual bundle size before assuming a quick trim will fix it: shaving a multi-megabyte gap out of a Payload plus Next.js bundle is rarely realistic. Upgrading to the Workers Paid plan through the Cloudflare dashboard's Workers plans page is usually the faster path to a working deployment.
20. Production checklist
The API token is scoped to one intended account and stored outside Git.
D1 and R2 names clearly identify the application and environment.
database_id matches the intended remote D1 database.
D1 and R2 binding names match the Payload configuration.
Remote migration behavior has been verified with a SQL query.
R2 remains private unless public access is an explicit product requirement.
Upload collection access rules have been tested anonymously and as an authenticated user.
PAYLOAD_SECRET is stored as a Worker secret.
Cloudflare binding types and Payload types are current.
Schema migrations are committed and reviewed.
Backup/Time Travel and recovery procedures are understood.
Exposed or temporary provisioning tokens have been rotated.
The Worker bundle size fits the account's plan limit (3 MiB free, 10 MiB paid).
FAQ
Can I use Cloudflare D1 with an existing Payload project that runs on PostgreSQL or MongoDB?
Not as a drop-in swap. D1 is SQLite, so Postgres or MongoDB-specific queries, JSON operators, and migrations need rewriting for the @payloadcms/db-d1-sqlite adapter. Treat it as a data migration project, not a configuration change.
Do I need Cloudflare R2 access keys inside the Payload application itself?
No. The Worker receives R2 through a native binding at runtime, so the application only needs cloudflare.env.R2. Access keys matter only if a separate tool outside Cloudflare needs S3-compatible access to the same bucket.
What happens if I forget to set remote: true in wrangler.jsonc?
Local Wrangler processes, including Payload CLI migrations, write to .wrangler/state instead of the real Cloudflare resource. The migration command can report success while the remote D1 database and R2 bucket stay untouched.
Can this same Payload configuration run outside Cloudflare Workers, for example on a plain Node.js server?
Only with changes. getCloudflareContext() and the D1/R2 bindings are Workers-specific. A Node deployment needs a different database adapter, such as Postgres or MongoDB, and a different storage adapter in place of this Cloudflare-native setup.
Does the D1 adapter support every feature available in Payload's PostgreSQL adapter?
No. D1 runs on SQLite, so certain query patterns, extensions, and scaling characteristics differ from Postgres. Check Payload's SQLite documentation and D1's platform limits before committing a production workload to this stack.
Conclusion
This setup replaces a conventional database connection and disk-based upload folder with two Cloudflare bindings: D1 for Payload's documents and R2 for uploaded files. The configuration in this guide keeps those bindings available correctly across three contexts, the deployed Worker, local development, and Payload's own CLI commands, which is where most Payload-on-Cloudflare setups run into trouble. Running the verification steps at each stage, especially the explicit --remote queries against D1, confirms the resources are wired up correctly before the app ships to production.
Let me know in the comments if you have questions, and subscribe for more practical development guides.