Skip to main content

Admin / Media / Deploy — The Operations Layer

Goals for this chapter

  • Find the customization points for the Admin UI (logo / Custom Components / theme)
  • See the overall shape of the plugin ecosystem and the everyday plugins
  • Write the basic pattern for swapping Media from local → S3
  • Understand how to operate the Postgres adapter (db.push vs payload migrate)
  • Know the minimal shape of background work and scheduled jobs with the Jobs Queue
  • Develop a decision rule for Vercel / Docker / Railway / Fly deployments
  • Glance at AI Auto-Embedding and close out the series

7.1 Admin UI customization

According to the official docs (Admin Overview), the Admin Panel is built in TypeScript + React and runs as part of the (payload) route group under the Next.js App Router. The admin property is the customization hub.

Main admin options

OptionRole
userWhich Collection authenticates ("admins", "users", …)
componentsUI overrides / injections
livePreviewReal-time preview (chapter 06)
autoLoginAuto-login for development
avatarProfile picture display
dateFormatDate display format
themeForce theme ('light' | 'dark' | 'all')

Plugging in Custom Components

import { buildConfig } from 'payload'

export default buildConfig({
admin: {
user: 'users',
components: {
beforeNavLinks: ['./components/CustomNavBanner'],
afterDashboard: ['./components/DashboardWidget'],
graphics: {
Logo: './components/Logo',
Icon: './components/Icon',
},
},
},
// ...
})

You get hooks like beforeNavLinks / afterNavLinks / beforeDashboard / afterDashboard, and you can drop in either Server or Client components. graphics.Logo swaps the logo, and custom.scss overrides CSS — together these are enough for white-label branding.

Admin features added mid-3.x (overview)

The 3.x line has kept adding management features around the Admin Panel. The deep dives live in the official docs; knowing these exist helps with selection and operations decisions:

  • Trash (soft delete) — with trash: true on a Collection, deletion becomes "moved to trash" with a deletedAt timestamp, restorable (or permanently deletable) from the Admin UI's Trash view (Trash)
  • Folders (beta) — organize documents into folders across Collections. Set folders: true on the Collection and configure folders at the config level (Folders; the docs note it's beta and may change in minor updates)
  • Query Presets — save and share list filters, columns, and sort orders with your team. Enable per Collection with enableQueryPresets: true (Query Presets)

7.2 The plugin ecosystem

Per the official docs (Plugins Overview), plugins are added by listing them in config.plugins. The plugin signature is a pure function that takes the existing Config and returns a modified one:

type Plugin = (incomingConfig: Config) => Config

Major official plugins maintained by the Payload team

PluginUse
Form BuilderBuild forms in the Admin UI, serve as JSON to the frontend
SEOBulk-add OGP / meta-tag / sitemap fields
SearchAuto-build a search-index Collection
Nested DocsHierarchical Collections (page trees etc.)
RedirectsManage redirect rules from the Admin UI
StripeStripe integration (customer / subscription sync)
Multi-TenantMulti-tenancy (data isolation per tenant)
SentryError reporting
Import / ExportData import / export
MCPModel Context Protocol integration
EcommerceE-commerce starter kit
Cloud StorageLegacy generic storage plugin (for S3 specifically, prefer @payloadcms/storage-s3 — see next section)

Usage example

import { buildConfig } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { searchPlugin } from '@payloadcms/plugin-search'

export default buildConfig({
collections: [/* ... */],
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
generateTitle: ({ doc }) => `${doc.title} | My Site`,
}),
searchPlugin({
collections: ['posts'],
}),
],
})

Plugins run before the config is normalized, so they can add Collections, inject Hooks into existing Collections, and so on.

7.3 Media (Uploads) — local vs S3

Basics of an upload-enabled Collection

According to the official docs (Upload Overview), adding the upload property gives a Collection file-upload behavior, and filename / mimeType / filesize are added automatically.

import path from 'path'

export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: path.resolve(__dirname, '../media'),
mimeTypes: ['image/*'],
imageSizes: [
{ name: 'thumbnail', width: 400, height: 300, position: 'centre' },
{ name: 'card', width: 768, height: undefined }, // height auto
{ name: 'hero', width: 1920, height: 1080 },
],
adminThumbnail: 'thumbnail',
focalPoint: true,
},
fields: [
{ name: 'alt', type: 'text' },
],
}

imageSizes — automatic resizing

According to the official docs, the entries in imageSizes are generated automatically through the Sharp library. height: undefined keeps aspect ratio, position controls crop placement, and withoutEnlargement controls "what to do when the source is smaller than the target."

Switching to S3

The @payloadcms/storage-s3 plugin swaps storage over to S3 (and any S3-compatible bucket). This is the successor to the 2.x-era @payloadcms/plugin-cloud-storage.

import { s3Storage } from '@payloadcms/storage-s3'

export default buildConfig({
collections: [Media],
plugins: [
s3Storage({
collections: {
media: true, // Use S3 for the Media collection
},
bucket: process.env.S3_BUCKET!,
config: {
region: process.env.S3_REGION!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
},
clientUploads: true, // sidestep Vercel's 4.5MB body cap (see below)
}),
],
})

config accepts a full S3ClientConfig from @aws-sdk/client-s3, which means the same syntax works for Cloudflare R2 / DigitalOcean Spaces / Backblaze B2 and every other S3-compatible store.

Bucket-level design (Block Public Access, encryption, versioning, CORS, and the prerequisites for presigned URLs) is covered in Practical AWS Guide Chapter 5: S3.

Why clientUploads

Vercel's serverless functions cap request bodies at 4.5 MB, so uploading larger images through the Admin UI fails. With clientUploads: true, the browser uploads directly to S3 via a presigned URL, bypassing the server entirely. On Vercel, this is effectively required.

7.4 Picking a DB adapter

AdapterPackageNotes
Postgres@payloadcms/db-postgresStable in 3.x / Drizzle ORM + node-postgres / the default in this series
Vercel Postgres@payloadcms/db-vercel-postgresOptimized for Vercel Postgres
MongoDB@payloadcms/db-mongodbThe traditional choice since 1.x / flexible schemas
SQLite@payloadcms/db-sqliteLocal development / small deployments / transactions are off by default

If you're unsure: a real RDB in production → Postgres; lots of schema churn → MongoDB; keep it on one machine → SQLite.

7.5 Migration strategy

According to the official docs (Migrations), the Postgres and SQLite adapters borrow Drizzle ORM's migration machinery.

Commands

CommandUse
payload migrateApply outstanding migrations in order
payload migrate:createGenerate a new migration file (from the current schema diff)
payload migrate:statusCheck what's applied
payload migrate:downRoll back the last batch
payload migrate:refreshRoll back everything, then re-apply
payload migrate:resetRoll back every migration
payload migrate:freshReset the DB completely and rebuild

Migration file shape

// migrations/20260616_add_posts_status.ts
import type { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-postgres'

export async function up({ payload, req }: MigrateUpArgs): Promise<void> {
// schema change
}

export async function down({ payload, req }: MigrateDownArgs): Promise<void> {
// revert
}

db push (dev) vs payload migrate (prod)

ModeWherePurpose
db.push: true (default in dev)Local DBPush schema changes instantly, no migration files
payload migrateProduction DBRun in CI/CD, migration files required

The official docs are explicit: never mix push and migrate on the same local DB. The standard flow is to iterate fast with push locally, then migrate:create to freeze a migration before release.

Typical CI flow

// package.json
{
"scripts": {
"build": "next build",
"ci": "payload migrate && pnpm build"
}
}

Running payload migrate before every deploy keeps your production DB in lockstep with the code.

7.6 Jobs Queue (background work)

For work that belongs outside the request — sending email, calling external APIs, heavy aggregation — PayloadCMS ships a Jobs Queue as a core feature (Jobs Queue (official docs)). There's no separate job infrastructure to stand up: task definitions through scheduled execution all live in the config.

Defining tasks and queueing

Define slug / inputSchema / handler under jobs.tasks, then enqueue with payload.jobs.queue():

// payload.config.ts
export default buildConfig({
// ...
jobs: {
tasks: [
{
slug: 'sendEmail',
inputSchema: [
{ name: 'userId', type: 'text', required: true },
],
handler: async ({ input, req }) => {
const user = await req.payload.findByID({
collection: 'users',
id: input.userId,
})
await req.payload.sendEmail({
to: user.email,
subject: 'Welcome!',
html: '...',
})
return { output: {} }
},
},
],
},
})
// Enqueue from a Server Action / Hook / custom endpoint
await payload.jobs.queue({
task: 'sendEmail',
input: { userId: '123' },
waitUntil: new Date('2026-12-25T00:00:00Z'), // (optional) delay execution
})

Cron schedules and autoRun

Add schedule to a task definition to enqueue it on a cron cadence, and let an autoRun runner process the queue:

jobs: {
tasks: [
{
slug: 'dailyDigest',
schedule: [{ cron: '0 8 * * *', queue: 'daily' }], // enqueue daily at 8:00
handler: async ({ req }) => {
/* ... */
return { output: {} }
},
},
],
autoRun: [
{ cron: '* * * * *', queue: 'daily', limit: 10 }, // check the queue every minute
],
}

schedule (the enqueuer) and autoRun (the runner) must point at the same queue name.

The serverless (Vercel) caveat

The official docs are explicit: "autoRun is intended for use with a dedicated server that is always running, and should not be used on serverless platforms like Vercel" (Queues (official docs)). On serverless, hit the jobs endpoint GET /api/payload-jobs/run from an external scheduler instead. On Vercel, register that path under crons in vercel.json and set the CRON_SECRET environment variable — Vercel then sends Authorization: Bearer ${CRON_SECRET} automatically, protecting the endpoint. On an always-on Node server (Docker / VPS, section 7.7), autoRun works as-is.

Workflows (chaining tasks) and retry controls are covered in the official docs. For this series, knowing the Jobs Queue exists in core is enough.

7.7 Deployment — four options

PayloadCMS basically runs anywhere Node.js runs (Edge is out, see chapter 06). The main paths:

Vercel

The fastest path. Vercel Postgres + @payloadcms/db-vercel-postgres is the canonical pairing.

Things to watch for:

  • Function timeout: with fluid compute (on by default), Vercel Functions default to 300 seconds (5 minutes). The ceiling is 300 seconds on Hobby and 800 seconds on Pro / Enterprise, or up to 1,800 seconds (30 minutes) with the beta extended max duration (Vercel: Configuring Maximum Duration, as of 2026-07). Bulk imports and migrations need care
  • 4.5 MB body cap: large Admin UI uploads fail; @payloadcms/storage-s3 with clientUploads: true solves it (section 7.3)
  • Cold start: function-based, so the first request after an idle period is slow
// vercel.json
{
"functions": {
"app/(payload)/api/**/route.ts": { "maxDuration": 60 }
}
}

Docker / your own Node server

For heavy-editor workflows or long-running jobs, a Node server on a VPS fits better. No cold start, no function timeout, no body cap.

The official Docker example (from node:24-alpine, multi-stage, output: 'standalone'):

# Excerpt
FROM node:24-alpine AS base
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build

FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=base /app/.next/standalone ./
COPY --from=base /app/.next/static ./.next/static
COPY --from=base /app/public ./public
CMD ["node", "server.js"]

Don't forget output: 'standalone' in next.config.

Railway / Fly / Render

"Managed container" hosts. They build and bring up your container — somewhere between Docker and Vercel: easier cost story than Vercel, no timeout caps. The official line is that Payload runs anywhere Next.js runs.

Cloudflare Workers (D1 + R2)

One-click Cloudflare Workers deployment shipped on October 3, 2025 (official blog post). The official template with-cloudflare-d1 sets you up with Workers + D1 (DB) + R2 (Storage).

Constraints:

  • The Workers Paid plan is required (the Free plan's 3MB Workers size limit is too small)
  • Pino-pretty depends on Node APIs, so the template replaces it with a custom console logger
  • GraphQL currently has issues on Workers (upstream — see the template's docs)
  • The legacy Cloudflare Pages is not the target; deployment goes through Workers

The underlying constraint ("PayloadCMS doesn't run on the Edge runtime", chapter 06 section 6.5) still holds — on Cloudflare you're using a Node-compatible runtime through Workers. This series treats Cloudflare as a viable option when the constraints match your requirements.

7.8 Payload Cloud (closed to new sign-ups)

Since the Figma acquisition in June 2025, Payload Cloud has stopped accepting new sign-ups. Existing customers retain access, but new projects need to self-host using the four options above (source: Payload is joining Figma! (official blog) / GitHub Discussion #12843).

Given that OSS / self-host is the core of PayloadCMS anyway, not depending on Payload Cloud is the safer long-term call.

7.9 AI Auto-Embedding (sketch)

The official docs top page (What is Payload) lists AI Auto-Embedding as an enterprise feature. The idea: have Payload auto-generate vector embeddings from your Collection content for use as a RAG (Retrieval-Augmented Generation) data source.

If you want to build RAG on the OSS core, you can do it by hand:

  • A Hook (afterChange) extracts the text
  • Call an embedding API (OpenAI / Cohere / Voyage, …)
  • Store in a vector DB (pgvector / Pinecone / Weaviate, …)
  • Expose a search endpoint via endpoints

The enterprise version takes a lot of the wiring off your hands.

7.10 Environment variable cheat sheet

Minimum set for running PayloadCMS in production:

# Payload itself
PAYLOAD_SECRET=a long random string for production (32+ chars recommended)
DATABASE_URL=postgresql://user:pass@host:5432/db
PAYLOAD_PUBLIC_SERVER_URL=https://example.com

# Preview
PREVIEW_SECRET=a separate token for draft preview

# Media (if using S3)
S3_BUCKET=my-bucket
S3_REGION=us-east-1
S3_ACCESS_KEY=...
S3_SECRET_KEY=...

# Next.js frontend
NEXT_PUBLIC_SERVER_URL=https://example.com

PAYLOAD_SECRET should be long and random. PAYLOAD_CONFIG_PATH is only needed when the config sits in a non-standard location.

Implementation example — Postgres migration with raw SQL (up / down)

payload migrate:create scaffolds a migration file that exports up and down functions. With the Postgres adapter you can pass an sql template literal to db.execute() to run raw SQL (Migrations doc).

// migrations/20260616_100000_add_archived_flag.ts
import { type MigrateUpArgs, type MigrateDownArgs, sql } from '@payloadcms/db-postgres'

export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// Schema changes typically flow through `payload generate:db-schema` + `migrate:create`,
// but raw SQL is the right tool for data backfills or custom indexes.
await db.execute(sql`
ALTER TABLE posts
ADD COLUMN archived BOOLEAN NOT NULL DEFAULT false
`)

// Touch existing data — the official Postgres example uses `SELECT * from posts`
const { rows: posts } = await db.execute(sql`SELECT id FROM posts WHERE _status = 'published'`)
payload.logger.info({ migrated: posts.length }, 'archived flag initialized')
}

export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE posts
DROP COLUMN archived
`)
}

Notes:

  • import { sql } from '@payloadcms/db-postgres' — adapter-specific (SQLite users import from @payloadcms/db-sqlite)
  • db.execute() receives the sql template literal and runs the SQL; the result is { rows: T[] }
  • The down function inverts up — make rollbacks possible in production
  • payload.logger is the same pino-style API you saw in chapter 04 (audit logs)
The official docs don't show direct Drizzle ORM usage

The official docs focus on raw SQL examples like the one above. A full example using Drizzle's query builder inside a migration file isn't documented. If you want the type-safe path, read Drizzle's own docs alongside.

Source: Migrations (official docs)

Wrap-up

  • Customize the Admin UI via admin.components, rebrand with graphics.Logo and custom.scss
  • Plugins are just an array under config.plugins; the team ships SEO / Form Builder / Search / Stripe / Multi-Tenant / Sentry and more
  • Move Media to S3 with one line of s3Storage(); on Vercel, clientUploads: true is effectively mandatory
  • Postgres adapter: never mix dev push with payload migrate, and always run migrate in CI
  • Pick deployment from Vercel / Docker / Railway-style / a bare Node server for the use case. Edge runtime is essentially out; Cloudflare works via Workers (Paid plan, with constraints)
  • The Jobs Queue is core: task definitions + payload.jobs.queue() + schedule / autoRun. On serverless, skip autoRun and hit /api/payload-jobs/run from an external cron
  • Mid-3.x Admin additions: Trash / Folders (beta) / Query Presets — reach for the official docs when you need them
  • Payload Cloud stopped accepting new sign-ups in June 2025; self-host is the default
  • AI Auto-Embedding is enterprise; on OSS you can roll your own with a Hook plus a vector DB
  • Migrations support raw SQL: import { sql } from '@payloadcms/db-postgres' and pass the sql template literal to db.execute(), with symmetric up / down functions

Series wrap-up

Across seven chapters we've walked the path of "co-locate PayloadCMS in a Next.js project and write everything from data model through operations in one TypeScript codebase":

The next step is to run create-payload-app on your own project and start with a single minimal Collection. Reading the official docs page by page alongside the mental model you've built here is the fastest way to make it stick.

References