---
title: "Payload CMS on Cloudflare: Complete D1 + R2 Setup Guide"
slug: "payload-cms-cloudflare-d1-r2-setup"
published: "2026-08-11"
updated: "2026-08-13"
categories:
  - "Cloudflare"
tags:
  - "Payload CMS on Cloudflare"
  - "Cloudflare D1"
  - "Cloudflare R2"
  - "Wrangler bindings setup"
  - "Payload migrations remote database"
  - "OpenNext Cloudflare"
  - "@payloadcms/db-d1-sqlite"
  - "@payloadcms/storage-r2"
  - "Payload CLI migrate remote"
  - "D1 R2 setup guide"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "cloudflare d1"
  - "cloudflare r2"
  - "cloudflare workers"
  - "wrangler"
status: "stable"
llm-purpose: "Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Cloudflare D1"
  - "Access to Cloudflare R2"
  - "Access to Cloudflare Workers"
  - "Access to Wrangler"
llm-outputs:
  - "Completed outcome: Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…"
---

**Summary Triples**
- (Cloudflare D1, replaces, a disk-based or server database for Payload documents (used via @payloadcms/db-d1-sqlite))
- (Cloudflare R2, replaces, local upload folder; used via @payloadcms/storage-r2 as Payload storage adapter)
- (wrangler.toml, must declare, D1 and R2 bindings so a deployed Worker can access the remote DB and storage)
- (Payload migration process, must be run with, remote: true and remoteBindings configured so migrations write to the real D1 instance, not local Miniflare)
- (Local development and CLI, require, the same binding names (or mapped equivalents) and environment configuration as the deployed Worker to avoid drift)
- (@payloadcms/db-d1-sqlite, is compatible with, Cloudflare D1 for Payload document storage (tested with payload@3.88.0))
- (@payloadcms/storage-r2, is used to, store uploads in Cloudflare R2 when configured as Payload's storage adapter)
- (Miniflare simulation, can cause, migrations to appear successful while leaving the remote D1 empty if remoteBindings/remote:true are not used)

### {GOAL}
Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…

### {PREREQS}
- Access to Payload CMS
- Access to Cloudflare D1
- Access to Cloudflare R2
- Access to Cloudflare Workers
- Access to Wrangler

### {STEPS}
1. Review architecture and trade-offs
2. Install prerequisites and packages
3. Create a Cloudflare API token
4. Configure local CLI credentials
5. Provision D1 database and R2 bucket
6. Set names and bindings in wrangler.jsonc
7. Generate Cloudflare binding types
8. Configure Payload to use D1 and R2
9. Create and commit Payload migrations
10. Apply migrations locally and remotely
11. Verify remote D1 and R2 behavior
12. Follow production checklist and backups

<!-- llm:goal="Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Cloudflare D1" -->
<!-- llm:prereq="Access to Cloudflare R2" -->
<!-- llm:prereq="Access to Cloudflare Workers" -->
<!-- llm:prereq="Access to Wrangler" -->
<!-- llm:output="Completed outcome: Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…" -->

# Payload CMS on Cloudflare: Complete D1 + R2 Setup Guide
> Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…
Matija Žiberna · 2026-08-11

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:

```text
Browser
  |
  v
Next.js + Payload on Cloudflare Workers
  |-- D1 binding --> Cloudflare D1 (documents, users, relationships, migrations)
  `-- R2 binding --> Cloudflare R2 (images, PDFs, imports, generated files)
```

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](https://developers.cloudflare.com/d1/platform/limits/). D1 is not a drop-in replacement for every PostgreSQL or MongoDB workload. For a similar production setup on a different cloud, see <a href="https://www.buildwithmatija.com/blog/payload-cms-azure-production-architecture">Payload CMS on Azure: Production Architecture Guide</a>.

### 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](https://developers.cloudflare.com/workers/platform/pricing/) and [Worker limits](https://developers.cloudflare.com/workers/platform/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:

```sh
pnpm add -D wrangler@latest
pnpm exec wrangler --version
```

Cloudflare recommends a project-local Wrangler installation. See the official [Wrangler installation guide](https://developers.cloudflare.com/workers/wrangler/install-and-update/).

Install the Payload and OpenNext integrations:

```sh
pnpm add @payloadcms/db-d1-sqlite @payloadcms/storage-r2 @opennextjs/cloudflare
```

Keep all `@payloadcms/*` packages on exactly the same version as `payload`.

## 3. Create a Cloudflare API token

Wrangler can use browser-based OAuth with `pnpm exec wrangler login`, but an API token is more convenient for repeatable CLI and CI workflows.

1. Open the Cloudflare dashboard.
2. For a user token, go to **My Profile → API Tokens**. For an account-owned service token, go to **Manage Account → API Tokens**.
3. Select **Create Token**, then create a custom token.
4. Give it a descriptive name, such as `payload-d1-r2-provisioning`.
5. Add these account permissions:
   - **D1 Edit**
   - **Workers R2 Storage Write**
   - Add **Workers Scripts Write** only if the same token will deploy the Worker.
6. Restrict the token to the intended Cloudflare account.
7. Add an expiration date when practical.
8. Create the token and copy it immediately. Cloudflare shows the secret only once.

The authoritative permission names are listed in Cloudflare's [API token permissions](https://developers.cloudflare.com/fundamentals/api/reference/permissions/), and the dashboard flow is covered in [Create API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/).

Never paste a token into source files, `wrangler.jsonc`, screenshots, issues, or chat logs.

## 4. Store local CLI credentials safely

Add `.env` to `.gitignore`:

```gitignore
.env
.env.*
!.env.example
```

Create `.env`:

```dotenv
CLOUDFLARE_API_TOKEN=<YOUR_API_TOKEN>
CLOUDFLARE_ACCOUNT_ID=<YOUR_ACCOUNT_ID>
PAYLOAD_SECRET=<A_LONG_RANDOM_APPLICATION_SECRET>
```

`CLOUDFLARE_ACCOUNT_ID` is optional when the token exposes only one account, but setting it removes ambiguity in automation.

Commit a safe `.env.example` instead:

```dotenv
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
PAYLOAD_SECRET=
```

Wrangler supports `CLOUDFLARE_API_TOKEN` and loads project `.env` files. See [Wrangler system environment variables](https://developers.cloudflare.com/workers/wrangler/system-environment-variables/).

Verify authentication:

```sh
pnpm exec wrangler whoami
```

If it reports `Invalid access token`, create a new token rather than repeatedly reusing or broadening an exposed credential.

## 5. Choose resource and binding names

Use different names for the remote resources and the bindings used by code:

| Purpose     | Example remote name      | Binding used in code |
| ----------- | ------------------------ | --------------------- |
| D1 database | `my-payload-app`         | `D1`                  |
| R2 bucket   | `my-payload-app-private` | `R2`                  |

The remote name identifies a resource in Cloudflare. The binding is the property available to the Worker, such as `cloudflare.env.D1`.

The examples below use:

```sh
APP_NAME=my-payload-app
BUCKET_NAME=my-payload-app-private
```

Use explicit names in CI instead of relying on shell variables if your automation environment does not preserve them between steps.

## 6. Create the D1 database

First check whether the database already exists:

```sh
pnpm exec wrangler d1 list
```

Create it when no exact-name match exists:

```sh
pnpm exec wrangler d1 create my-payload-app --location weur
```

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](https://developers.cloudflare.com/d1/wrangler-commands/).

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.

## 7. Create the private R2 bucket

List existing buckets:

```sh
pnpm exec wrangler r2 bucket list
```

Create the bucket when it does not exist:

```sh
pnpm exec wrangler r2 bucket create my-payload-app-private --location weur
```

Verify it:

```sh
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](https://developers.cloudflare.com/r2/get-started/cli/). If you ever need a Next.js route that talks to R2 directly instead of through Payload's storage adapter, <a href="https://www.buildwithmatija.com/blog/how-to-upload-files-to-cloudflare-r2-nextjs">How to Upload Files to Cloudflare R2 in a Next.js App</a> covers that pattern.

## 8. Configure `wrangler.jsonc`

Add the bindings to the Worker configuration:

```jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-payload-app",
  "main": ".open-next/worker.js",
  "compatibility_date": "<CURRENT_DATE>",
  "compatibility_flags": ["nodejs_compat"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS",
  },
  "d1_databases": [
    {
      "binding": "D1",
      "database_name": "my-payload-app",
      "database_id": "<D1_DATABASE_UUID>",
      "remote": true,
    },
  ],
  "r2_buckets": [
    {
      "binding": "R2",
      "bucket_name": "my-payload-app-private",
      "remote": true,
    },
  ],
}
```

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:

```sh
pnpm exec wrangler types --env-interface CloudflareEnv
```

The result should contain equivalents of:

```ts
interface CloudflareEnv {
  D1: D1Database
  R2: R2Bucket
  PAYLOAD_SECRET: string
}
```

Regenerate these types whenever binding names or Wrangler variables change. OpenNext documents this workflow in [Bindings](https://opennext.js.org/cloudflare/bindings).

## 10. Enable uploads in Payload

R2 storage applies to upload-enabled collections. A minimal collection looks like this:

```ts
import type { CollectionConfig } from 'payload'

export const Media: CollectionConfig = {
  slug: 'media',
  access: {
    read: ({ req }) => Boolean(req.user),
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
    },
  ],
  upload: {
    mimeTypes: ['image/*', 'application/pdf'],
  },
}
```

Payload automatically adds file metadata fields and upload operations to an upload-enabled collection. See [Payload uploads](https://payloadcms.com/docs/upload/overview).

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:

1. a deployed Worker;
2. local Next.js development;
3. 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:

```ts
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'
import { r2Storage } from '@payloadcms/storage-r2'
import { getCloudflareContext, type CloudflareContext } from '@opennextjs/cloudflare'
import { buildConfig } from 'payload'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { GetPlatformProxyOptions } from 'wrangler'

import { Media } from './collections/Media'
import { Users } from './collections/Users'
import { migrations } from './migrations'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

const payloadCLICommands = [
  'generate:types',
  'generate:importmap',
  'migrate',
  'migrate:create',
  'migrate:status',
]

const isPayloadCLI = process.argv.some((argument) => payloadCLICommands.includes(argument))
const isProduction = process.env.NODE_ENV === 'production'

const cloudflare =
  isPayloadCLI || !isProduction
    ? await getCloudflareContextFromWrangler()
    : await getCloudflareContext({ async: true })

export default buildConfig({
  admin: {
    user: Users.slug,
    importMap: {
      baseDir: path.resolve(dirname),
    },
  },
  collections: [Users, Media],
  secret: process.env.PAYLOAD_SECRET || '',
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
  db: sqliteD1Adapter({
    binding: cloudflare.env.D1,
    prodMigrations: migrations,
  }),
  plugins: [
    r2Storage({
      bucket: cloudflare.env.R2,
      collections: {
        media: true,
      },
    }),
  ],
})

// Keep Wrangler out of the deployed worker bundle. This branch is used only by
// Payload CLI commands and local development.
function getCloudflareContextFromWrangler(): Promise<CloudflareContext> {
  return import(/* webpackIgnore: true */ `${'__wrangler'.replaceAll('_', '')}`).then(
    ({ getPlatformProxy }) =>
      getPlatformProxy({
        configPath: path.resolve(dirname, '../wrangler.jsonc'),
        environment: process.env.CLOUDFLARE_ENV,
        remoteBindings: isProduction,
      } satisfies GetPlatformProxyOptions),
  )
}
```

If more than one upload collection should use R2, add every collection slug:

```ts
collections: {
  media: true,
  imports: true,
  generatedFiles: true,
}
```

The keys must exactly match registered Payload collection slugs. Payload's D1 adapter is documented under [SQLite: D1 Database](https://payloadcms.com/docs/database/sqlite), and storage behavior is covered by [Storage Adapters](https://payloadcms.com/docs/upload/storage-adapters). If R2 does not fit your needs and you want a comparable setup with an object storage provider outside Cloudflare, <a href="https://www.buildwithmatija.com/blog/backblaze-b2-direct-uploads-payload-cms">Backblaze B2 Direct Uploads With Payload CMS Jobs</a> 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:

```sh
cross-env NODE_ENV=production payload migrate
pnpm exec wrangler d1 execute D1 --remote --command "PRAGMA optimize"
```

For a cross-platform package script, install `cross-env` and add:

```json
{
  "scripts": {
    "deploy:database": "cross-env NODE_ENV=production payload migrate && wrangler d1 execute D1 --remote --command 'PRAGMA optimize'"
  }
}
```

Then run:

```sh
pnpm run deploy:database
```

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](https://developers.cloudflare.com/d1/get-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:

1. Start or deploy the Payload application.
2. Sign in to Payload Admin.
3. Upload a small permitted file to the `media` collection.
4. Confirm the Payload document contains the expected filename and metadata.
5. Confirm the R2 bucket's object count increases.
6. Download the file through Payload while authenticated.
7. Confirm an unauthenticated request is rejected when the collection is private.
8. 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; <a href="https://www.buildwithmatija.com/blog/cloudflare-cdn-cache-headers-self-hosted-s3-nextjs-payload-bug">Cloudflare CDN Cache Headers for Self-Hosted S3 Guide</a> 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:

1. D1 has `"remote": true` in `wrangler.jsonc`.
2. `getPlatformProxy()` receives `remoteBindings: true` in production.
3. 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.

Thanks,
Matija

## Further reading

- [Cloudflare API token creation](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)
- [Wrangler installation](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
- [Cloudflare D1 Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/)
- [Cloudflare R2 CLI](https://developers.cloudflare.com/r2/get-started/cli/)
- [OpenNext Cloudflare bindings](https://opennext.js.org/cloudflare/bindings)
- [Payload D1 adapter](https://payloadcms.com/docs/database/sqlite)
- [Payload storage adapters](https://payloadcms.com/docs/upload/storage-adapters)

## LLM Response Snippet
```json
{
  "goal": "Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS on Cloudflare: Complete D1 + R2 Setup Guide\" cover?",
      "answer": "Payload CMS on Cloudflare: learn to provision D1 and R2, configure Wrangler bindings, and run production migrations that write to the remote database…"
    }
  ]
}
```