Complete Guide: Deploying Payload CMS to Cloudflare Workers
In today’s web development world, combining a powerful headless CMS with a high-performance edge computing platform can deliver incredible performance and user experience for both developers and end users. Payload CMS is known for its flexibility and amazing developer experience, while Cloudflare Workers provides globally distributed serverless computing power.
This tutorial will walk you through every step of deploying a fully-featured Payload CMS application to Cloudflare’s global network, complete with Cloudflare D1 database and R2 storage integration.
Introduction
What’s Payload CMS All About?
Payload CMS is an open-source headless CMS built on Node.js and TypeScript. But here’s the thing - it’s not just a content management system, it’s actually a full application framework. What makes it awesome:
- Super Flexible: Uses a code-first approach to define your data structure, making customization a breeze
- Developer-Friendly: Gives you type-safe APIs, powerful plugin architecture, and a beautiful admin interface
- Feature-Rich: Comes with authentication, file uploads, version control, multi-language support, and tons more out of the box
What Are Cloudflare Workers?
Cloudflare Workers is a serverless computing platform that lets you run JavaScript and WebAssembly code on hundreds of Cloudflare edge locations worldwide. This means your code runs closer to your users, which dramatically reduces latency. Here’s why it rocks:
- Global Edge Deployment: Your code automatically deploys to the global network - no servers or containers to manage
- Lightning Fast: Built on V8 Isolates technology, so it starts up in milliseconds
- Auto-Scaling: Handles traffic spikes automatically without breaking a sweat
- Cost-Effective: Pay only for what you use, plus they have generous free tiers
Why Deploy Payload CMS on Cloudflare?
When you combine Payload CMS with Cloudflare Workers, you get the best of both worlds:
- Blazing Performance: Both your APIs and static assets are served through Cloudflare’s edge network, giving users worldwide lightning-fast access
- Seamless Integration: You can tap into Cloudflare’s entire ecosystem - use D1 instead of traditional databases, R2 instead of S3 for object storage, and build a full-stack app that runs entirely on the edge
- Zero Ops Hassle: Say goodbye to complex server management and focus on what matters - building your app
Getting Ready
Before we dive in, let’s make sure you’ve got everything you need:
Account Setup
- A Cloudflare account (free tier works great for getting started!)
- A Payload CMS account (only if you want to use their cloud services)
Tools You’ll Need
- Node.js (v18 or higher recommended) and npm (or pnpm/yarn if that’s your jam)
- Cloudflare’s CLI tool called Wrangler. Install it globally and log in:
npm install -g wrangler
wrangler login
Enable Cloudflare Services
- Head over to your Cloudflare dashboard and make sure you’ve got Workers, D1 database, and R2 storage enabled
Step-by-Step Deployment
Alright, let’s get this thing deployed! We’ll go through each step together.
Step 1: Create Your Payload CMS Project
First things first - let’s create a new Payload project using their official CLI tool:
npx create-payload-app my-payload-project
When it asks you to choose a template, make sure you pick TypeScript (trust me, you’ll thank me later). Then hop into your project directory:
cd my-payload-project
Step 2: Set Up Cloudflare D1 Database
By default, Payload uses MongoDB, but we’re going to swap that out for Cloudflare D1 - it’s way better for our edge setup.
Create your D1 database:
Fire up Wrangler CLI to create a new D1 database. Make sure to save the database_name
and database_id
it gives you back:
wrangler d1 create my-payload-db
Install all the packages we need:
npm install @payloadcms/db-d1-sqlite @payloadcms/payload-cloud @payloadcms/richtext-lexical @payloadcms/storage-r2 @opennextjs/cloudflare
Configure Payload to use D1:
Now let’s create or update your src/payload.config.ts
file with this modern setup:
// src/payload.config.ts
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import { CloudflareContext, getCloudflareContext } from '@opennextjs/cloudflare'
import { GetPlatformProxyOptions } from 'wrangler'
import { r2Storage } from '@payloadcms/storage-r2'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// Smart Cloudflare context detection
const cloudflare = process.argv.find((value) => value.match(/^(generate|migrate):?/))
? await getCloudflareContextFromWrangler()
: await getCloudflareContext({ async: true })
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: sqliteD1Adapter({ binding: cloudflare.env.D1 }),
plugins: [
payloadCloudPlugin(),
r2Storage({
bucket: cloudflare.env.R2,
collections: { media: true },
}),
],
})
// Helper function to get Cloudflare context from Wrangler
function getCloudflareContextFromWrangler(): Promise<CloudflareContext> {
return import(`${'__wrangler'.replaceAll('_', '')}`).then(({ getPlatformProxy }) =>
getPlatformProxy({
environment: process.env.CLOUDFLARE_ENV,
experimental: { remoteBindings: process.env.NODE_ENV === 'production' },
} satisfies GetPlatformProxyOptions),
)
}
Step 3: Create Collection Files
Now we need to create the collection files that our config is referencing. We’ve got Users
and Media
collections to set up.
Create the Users collection (src/collections/Users.ts
):
// src/collections/Users.ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [
// Email is added by default
// Add more fields as needed
],
}
Create the Media collection (src/collections/Media.ts
):
// src/collections/Media.ts
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'media',
imageSizes: [
{
name: 'thumbnail',
width: 400,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 1024,
position: 'centre',
},
],
adminThumbnail: 'thumbnail',
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
},
],
}
Create your R2 bucket:
wrangler r2 bucket create my-payload-uploads
Here’s what’s cool about this setup:
- In our
payload.config.ts
above, R2 storage is already configured through ther2Storage
plugin - File uploads go straight to Cloudflare R2 - no extra config needed
- Everything’s connected seamlessly through Cloudflare’s binding system
Step 4: Configure OpenNext Adapter
Following the latest best practices, we’re using OpenNext to deploy Payload CMS to Cloudflare. This gives us better compatibility and performance.
Install OpenNext:
npm install @opennextjs/cloudflare
Create OpenNext config:
Create an open-next.config.ts
file in your project root:
// open-next.config.ts
import type { OpenNextConfig } from '@opennextjs/cloudflare'
const config: OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
incrementalCache: 'dummy',
tagCache: 'dummy',
queue: 'dummy',
},
},
}
export default config
Update Next.js config:
Create or update your next.config.ts
file:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
serverComponentsExternalPackages: ['@payloadcms/db-postgres'],
},
images: {
unoptimized: true,
},
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
}
export default nextConfig
Step 5: Configure Wrangler
Create a wrangler.jsonc
file (note the .jsonc
extension - this lets us use comments):
{
"name": "my-payload-app",
"compatibility_date": "2024-03-20",
"compatibility_flags": ["nodejs_compat"],
"main": ".open-next/worker.js",
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "my-payload-db",
"database_id": "<your D1 database ID>"
}
],
"r2_buckets": [
{
"binding": "R2_BUCKET",
"bucket_name": "my-payload-uploads"
}
],
"vars": {
"PAYLOAD_SECRET": "a_very_long_and_secure_secret_key",
"R2_PUBLIC_DOMAIN": "https://your-public-r2-domain.com",
"CLOUDFLARE_ACCOUNT_ID": "<your Cloudflare Account ID>"
}
}
Key differences:
- Uses
.open-next/worker.js
as the entry point - Static assets directory is set to
.open-next/assets
- JSONC format supports comments
Step 6: Configure Build Scripts
Update the scripts in your package.json
with the build and deploy commands we need:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"build:open-next": "open-next build",
"deploy": "pnpm run build:open-next && wrangler deploy",
"wrangler": "wrangler",
"migrate:create": "payload migrate:create",
"migrate": "payload migrate"
}
}
Step 7: Local Development and Testing
First, authenticate with Cloudflare:
pnpm wrangler login
For local development: You can use the standard Next.js dev server for most development work:
pnpm dev
To test the Cloudflare environment locally: When you want to test how things work in the Cloudflare environment, build the OpenNext version first:
pnpm run build:open-next
pnpm wrangler dev
Step 8: Database Migrations
Before your first deployment, create and run database migrations:
# Create migration files
pnpm payload migrate:create
# Run migrations (this happens automatically during deployment)
pnpm payload migrate
Step 9: Deploy to Cloudflare’s Global Network
Once your local testing looks good, it’s time to deploy to the world:
pnpm run deploy
This command will:
- Build your Next.js app
- Use OpenNext to convert it to Cloudflare Workers format
- Run database migrations
- Deploy everything to Cloudflare
After deployment, Wrangler will give you a *.workers.dev
URL. Visit that URL and you should see your Payload CMS running live!
Wrapping Up and Next Steps
Congrats! You’ve successfully deployed a powerful Payload CMS application to Cloudflare’s global edge network.
Let’s recap what we did: We started with a standard Payload project, configured D1 database and R2 storage, used the OpenNext adapter to convert our Next.js app to Cloudflare Workers format, and deployed everything to the global edge network through Wrangler.
What you get with this modern architecture:
- Global Performance: Ultra-low latency through edge computing
- High Availability: Cloudflare’s global network keeps your service rock-solid
- Cost-Effective: Pay only for what you use, with generous free tiers
- Zero Ops: No server infrastructure to manage
- Modern Tech Stack: Built on the latest Next.js and OpenNext practices
Known Limitations and Gotchas
GraphQL Support: GraphQL support in the Workers environment isn’t complete yet, so stick with REST APIs for now.
Bundle Size Limits: Free tier Workers have a 1MB bundle size limit, paid plans get 10MB. For complex Payload apps, you’ll probably want to go with a paid plan.
Database Connections: D1 databases are accessed through bindings, not traditional connection strings. This might need special handling in some scenarios.
What to Do Next
- Custom Domain: Set up a professional custom domain for your Worker in the Cloudflare dashboard
- Cloudflare Access: Add an extra security layer to your CMS admin panel
- Enable Logging: Turn on API logs in the Cloudflare panel with one click (uses your quota though)
- CI/CD Setup: Integrate the deployment process into your continuous integration pipeline
- Explore Payload: Dive deeper into collections, globals, and field types
- Performance Tuning: Configure CDN and caching strategies for even better performance