Payload CMS now runs natively on Cloudflare Workers

Cloudflare TV has been quietly running on a new kind of CMS backend. Payload, the open-source headless CMS with more than 35,000 GitHub stars, now has an official template for deploying directly onto Cloudflare's developer platform. The template wires up a full Payload instance to Cloudflare D1 for the database and R2 for media storage, with a single-click deploy that takes care of all bindings.

The significance of this is that a fully functional CMS no longer requires a 24/7 server. Traditional CMS deployments need a persistent server process, which means buying or renting hardware capacity that sits idle when no one is using the site. Workers applications only invoke compute while a request is in flight. Payload on Workers combines the familiar editorial features — content versioning, an admin dashboard, community plugins, and asset management — with the per-request pricing and global distribution of the Workers model.

The Payload team had already migrated Cloudflare TV's own content management off a conventional CMS prior to this announcement. Their instance manages a library of over 2,000 episodes and 70,000 assets, with Payload's filtering and search keeping navigation manageable. They used the migration as a test bed for the new Workers runtime.

Bringing a Node application to Workers

Payload started in 2022 as a Node.js application and has natively supported the Next.js framework since 2024. The port to Workers was therefore made possible by the Cloudflare OpenNext adapter, which was released as generally available earlier this year. Following the official OpenNext migration guide, the team disabled connection pooling to comply with Worker runtime constraints on sharing connections across requests:

import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  …
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URI,
      maxUses: 1,
    },
  }),
  …
});

That produces newly established connections per request, which adds latency. Hyperdrive sits in front of the database to mitigate this, maintaining a persistent pool of connections via a network tunnel to the database server, and also caches queries:

import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { getCloudflareContext } from '@opennextjs/cloudflare';

const cloudflare = await getCloudflareContext({ async: true });

export default buildConfig({
  …
  db: postgresAdapter({
    pool: {
      connectionString: cloudflare.env.HYPERDRIVE.connectionString,
      maxUses: 1,    
    },
  }),
  …
});

D1 as the default backend

With the external Postgres setup hardened, the team turned its attention to D1, Cloudflare's serverless SQLite database. Payload officially supports SQLite through @payloadcms/db-sqlite, a Drizzle ORM-based adapter that works with libSQL. Since Drizzle also has an adapter for D1, the engineering team wrote a custom D1 adapter built on top of Payload's SQLite implementation.

The core difference between D1 and libSQL comes down to the shape of the result object. A small translation layer normalizes D1 output into the format the SQLite adapter expects:

export const execute: Execute<any> = function execute({ db, drizzle, raw, sql: statement }) {
  const executeFrom = (db ?? drizzle)!
  const mapToLibSql = (query: SQLiteRaw<D1Result<unknown>>) => {
    const execute = query.execute
    query.execute = async () => {
      const result: D1Result = await execute()
      const resultLibSQL: Omit<ResultSet, 'toJSON'> = {
        columns: undefined,
        columnTypes: undefined,
        lastInsertRowid: BigInt(result.meta.last_row_id),
        rows: result.results as any[],
        rowsAffected: result.meta.rows_written,
      }

      return Object.assign(result, resultLibSQL)
    }

    return query
  }

  if (raw) {
    const result = mapToLibSql(executeFrom.run(sql.raw(raw)))
    return result
  } else {
    const result = mapToLibSql(executeFrom.run(statement!))
    return result
  }
}

Beyond that glue code, the D1 binding is passed directly to the Drizzle constructor. Database migrations are executed during deployment using Wrangler's remote bindings, so no API tokens are needed during the migration step.

Media management through the R2 binding

Payload offers an official S3 storage adapter, and because R2 is S3-compatible, it would operate without modification. But to align with the binding-first approach taken for the database — and again avoid having to issue secrets — the team built a thin R2-specific storage layer for the new template:

import type { Adapter } from '@payloadcms/plugin-cloud-storage/types'
import path from 'path'

const isMiniflare = process.env.NODE_ENV === 'development';

export const r2Storage: (bucket: R2Bucket) => Adapter = (bucket) => ({ prefix = '' }) => {
  const key = (filename: string) => path.posix.join(prefix, filename)
  return {
    name: 'r2',
    handleDelete: ({ filename }) => bucket.delete(key(filename)),
    handleUpload: async ({ file }) => {
      // Read more: https://github.com/cloudflare/workers-sdk/issues/6047#issuecomment-2691217843
      const buffer = isMiniflare ? new Blob([file.buffer]) : file.buffer
      await bucket.put(key(file.filename), buffer)
    },
    staticHandler: async (req, { params }) => {
      // Due to https://github.com/cloudflare/workers-sdk/issues/6047
      // We cannot send a Headers instance to Miniflare
      const obj = await bucket?.get(key(params.filename), { range: isMiniflare ? undefined : req.headers })
      if (obj?.body == undefined) return new Response(null, { status: 404 })

      const headers = new Headers()
      if (!isMiniflare) obj.writeHttpMetadata(headers)

      return obj.etag === (req.headers.get('etag') || req.headers.get('if-none-match'))
        ? new Response(null, { headers, status: 304 })
        : new Response(obj.body, { headers, status: 200 })
    },
  }
}

Global reads without a global round-trip

D1 databases are assigned a primary location, no matter how the Worker application is distributed. For a broadly accessed CMS, that is a bottleneck for users far from the primary region. D1's global read replication short-circuits that when a database is configured as first-primary. Requests start at the nearest replica; subsequent reads can be served from wherever provides the lowest latency, whereas anything requiring writes is sent to the primary for consistency.

Drizzle has yet to implement native support for D1 sessions, but the binding's sub-optimal, yet valid "first-primary" behavior handles all of this correctly. The session is passed directly to Drizzle, eliminating the artificial post-query round-trip to the primary database:

this.drizzle = drizzle(this.binding.withSession("first-primary"), 
{ logger, schema: this.schema });

In their test setup, this was significant. P50 request wall-times dropped 60% across globally distributed calls connecting to a database provisioned in Eastern North America. For instance, two database calls are made for each request against their Payload admin — without the replica, users in other continents pay the round-trip to that region:

No read replicas

Read replicas enabled

Improvement

P50

300ms

120ms

-60%

P90

480ms

250ms

-48%

P99

760ms

550ms

-28%

What the template contains

Running this locally or via the deploy button gives you a working Payload installation bound directly to a new D1 database and R2 bucket. Out of the box it has just two data collections — users carrying sign-up data, and a simple set of media records — but expanding this into a full CMS with custom collections and relations is a matter of editing Payload configuration files rather than modifying infrastructure code.

The same deployment path used here, adapting a Next.js-era framing with the OpenNext adapter, is also attracting other CMS projects. SonicJs, designed for Workers, D1, and Astro, is specifically optimising for code-generation workflows where agentic code assistants like Claude and Codex carry out the heavy lifting in-manue. microfeed is a smaller distributed, self-hosted option written explicitly for this platform.