Tao
Tao

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.

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

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

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

Before we dive in, let’s make sure you’ve got everything you need:

  • A Cloudflare account (free tier works great for getting started!)
  • A Payload CMS account (only if you want to use their cloud services)
  • 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:

bash

npm install -g wrangler
wrangler login
  • Head over to your Cloudflare dashboard and make sure you’ve got Workers, D1 database, and R2 storage enabled

Alright, let’s get this thing deployed! We’ll go through each step together.

First things first - let’s create a new Payload project using their official CLI tool:

bash

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:

bash

cd my-payload-project

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:

bash

wrangler d1 create my-payload-db

Install all the packages we need:

bash

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:

typescript

// 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),
  )
}

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):

typescript

// 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):

typescript

// 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:

bash

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 the r2Storage plugin
  • File uploads go straight to Cloudflare R2 - no extra config needed
  • Everything’s connected seamlessly through Cloudflare’s binding system

Following the latest best practices, we’re using OpenNext to deploy Payload CMS to Cloudflare. This gives us better compatibility and performance.

Install OpenNext:

bash

npm install @opennextjs/cloudflare

Create OpenNext config: Create an open-next.config.ts file in your project root:

typescript

// 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:

typescript

// 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

Create a wrangler.jsonc file (note the .jsonc extension - this lets us use comments):

json

{
  "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

Update the scripts in your package.json with the build and deploy commands we need:

json

{
  "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"
  }
}

First, authenticate with Cloudflare:

bash

pnpm wrangler login

For local development: You can use the standard Next.js dev server for most development work:

bash

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:

bash

pnpm run build:open-next
pnpm wrangler dev

Before your first deployment, create and run database migrations:

bash

# Create migration files
pnpm payload migrate:create

# Run migrations (this happens automatically during deployment)
pnpm payload migrate

Once your local testing looks good, it’s time to deploy to the world:

bash

pnpm run deploy

This command will:

  1. Build your Next.js app
  2. Use OpenNext to convert it to Cloudflare Workers format
  3. Run database migrations
  4. 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!

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

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.

  • 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

Related Content