obsidian/wiki/payloadcms/production.md
2026-05-15 15:13:56 +01:00

7 KiB

tags topic sources created
payloadcms
tech-patterns
payloadcms
production__deployment.md
production__preventing-abuse.md
production__building-without-a-db-connection.md
performance__overview.md
2026-05-15

PayloadCMS — Production & Performance

Overview

Payload runs fully inside Next.js, so the Next.js build pipeline applies everywhere. Deploy anywhere Next.js runs: Vercel, Netlify, Docker/VPS, SST, AWS, Cloudflare Workers.

Required infra checklist before going live:

  • Database (Postgres or MongoDB) in the same region as the server
  • Persistent file storage (S3 / GCS / Azure Blob) if using uploads
  • Email provider
  • CDN (optional but recommended)

Setup

Build

npm run build   # runs next build
npm run start   # runs next start (production mode)

Environment variables (minimum)

PAYLOAD_SECRET=<long-random-string>  # must be hard to brute-force
DATABASE_URL=<connection-string>
PAYLOAD_CONFIG_PATH=dist/payload.config.js  # if needed

Key Patterns

Docker (multi-stage, standalone)

Set output: 'standalone' in next.config.js, then use this Dockerfile pattern:

FROM node:24-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
    elif [ -f package-lock.json ]; then npm ci; \
    else yarn --frozen-lockfile; fi

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
    elif [ -f package-lock.json ]; then npm run build; \
    else yarn run build; fi

FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next && chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD HOSTNAME="0.0.0.0" node server.js

Building without a DB connection (Docker CI pattern)

Next.js SSG attempts to call Payload Local API at build time → fails if no DB available.

Option 1 — experimental build mode (recommended):

# Step 1: compile only (no DB needed, no static generation)
pnpm next build --experimental-build-mode compile

# Step 2: generate static pages (when DB is available, e.g. at deploy time)
pnpm next build --experimental-build-mode generate

# If DB still not available at generate time, inline NEXT_PUBLIC_ vars:
pnpm next build --experimental-build-mode generate-env

Option 2 — opt out of SSG entirely (slower, simpler):

Add to every route segment that uses Payload Local API:

export const dynamic = 'force-dynamic'

Note: disables static optimization, every page renders dynamically on request.

Abuse prevention

Threat Config
Brute-force login maxLoginAttempts + lockTime on auth collections
Deep relationship bombs maxDepth on Payload config (default: 10, set as low as possible)
GraphQL complexity attacks graphQL.maxComplexity (each field = 1, relationships/uploads = 10)
Unused GraphQL surface graphQL.disable: true
CSRF Built-in — configure allowed origins in csrf array
CORS cors array in Payload config
Malicious uploads Restrict create/update access on upload collections; scan via ClamAV hook

Cloud storage adapters for uploads

Avoid ephemeral filesystems (Heroku, DigitalOcean Apps). Official adapters:

  • @payloadcms/storage-s3 — AWS S3
  • @payloadcms/storage-gcs — Google Cloud Storage
  • @payloadcms/storage-azure — Azure Blob
  • @payloadcms/storage-vercel-blob — Vercel
  • @payloadcms/storage-uploadthing

CosmosDB compatibility

import { mongooseAdapter, compatibilityOptions } from '@payloadcms/db-mongodb'

db: mongooseAdapter({
  url: process.env.DATABASE_URL,
  ...compatibilityOptions.cosmosdb,  // disables multi-doc transactions, uses find instead of subqueries
  indexSortableFields: true,
})

Config / Code Examples

Enable in each auth collection config when TLS is in place:

auth: {
  cookies: { secure: true, sameSite: 'strict' }
}

Direct DB calls for performance

Bypasses hooks, validation, access control — use only when safe:

// Update without fetching the document back (Postgres + MongoDB)
await payload.db.updateOne({
  collection: 'posts',
  id: post.id,
  data: { title: 'New Title' },
  returning: false,  // skip fetching updated doc, saves a round-trip
})

Block references (reduce config bloat)

// Define once at root
const config = buildConfig({
  blocks: [{ slug: 'TextBlock', fields: [{ name: 'text', type: 'text' }] }],
  collections: [
    { slug: 'posts', fields: [{ name: 'content', type: 'blocks', blockReferences: ['TextBlock'], blocks: [] }] },
    { slug: 'pages', fields: [{ name: 'content', type: 'blocks', blockReferences: ['TextBlock'], blocks: [] }] },
  ],
})

Reuse cached Payload instance

import { getPayload } from 'payload'
import config from '@payload-config'

const payload = await getPayload({ config })  // returns cached instance, safe to call repeatedly

Dev speed (Next.js 15)

{ "scripts": { "dev": "next dev --turbo" } }

Skip bundling server packages during dev (enabled by default in create-payload-app ≥ v3.28.0):

export default withPayload(nextConfig, { devBundleServerPackages: false })

Selective imports from @payloadcms/ui (frontend bundle)

// Bad — bundles entire UI library
import { Button } from '@payloadcms/ui'

// Good — tree-shaken, use in non-admin code
import { Button } from '@payloadcms/ui/elements/Button'

Admin Panel custom components can still use import { Button } from '@payloadcms/ui' — they are not bundled into the frontend.

Gotchas

  • Heroku / DigitalOcean Apps use ephemeral filesystems — uploads vanish on restart; use a cloud storage adapter
  • next build with SSG + Local API requires DB — use --experimental-build-mode compile in Docker CI to avoid this
  • CosmosDB does not support multi-document transactions — spread compatibilityOptions.cosmosdb preset
  • maxDepth defaults to 10 — set it as low as your UI allows; circular relationships can crash the server
  • payload.secret in production must be cryptographically strong — used to sign JWTs and cookies
  • Direct DB calls skip hooks/validation — never use for user-facing write paths
  • returning: false only available on payload.db.* methods, not the Local API
  • NEXT_PUBLIC_* vars not inlined in compile mode — run generate-env if you need them without a DB