Back to Blog
Tutorial10 min read2026-07-02

How to Deploy Payload CMS to Production

Payload is a code-first, TypeScript-native headless CMS that runs as part of your Node app. This tutorial covers its config-as-code model, database choice, file storage, and a clean production deployment.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

# How to Deploy Payload CMS to Production

Payload is a headless CMS aimed squarely at developers. Instead of clicking around an admin to define content models, you declare your collections in TypeScript config, and Payload generates the admin UI, a REST API, a GraphQL API, and full type definitions from that config. It runs as a Node application — often alongside Next.js — which shapes how you deploy it. This tutorial covers a clean production setup.

The config-as-code model

Payload's defining trait: your content schema lives in version-controlled code, not a database UI. A collection is a TypeScript object:

import { CollectionConfig } from 'payload/types';

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: { useAsTitle: 'title' },
  access: { read: () => true },           // public read
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'slug', type: 'text', required: true, unique: true },
    { name: 'content', type: 'richText' },
    { name: 'status', type: 'select', options: ['draft', 'published'] },
  ],
};

Because schema is code, changes go through pull requests and deploy like any other code change — a big win for teams.

Step 1: choose a database

Payload supports MongoDB and PostgreSQL (and other SQL via adapters). Pick based on your data shape; PostgreSQL is a great default for relational content and is widely supported by managed providers. Configure the adapter with an env-driven connection string:

// payload.config.ts
import { postgresAdapter } from '@payloadcms/db-postgres';

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL },
  }),
  secret: process.env.PAYLOAD_SECRET,    // required, stable
  collections: [Posts /* ... */],
});

PAYLOAD_SECRET signs tokens and cookies — keep it stable across deploys and replicas, or you log users out on every release.

Step 2: handle file uploads

Payload's upload-enabled collections store files. As with every CMS, local disk does not persist across container restarts. Use a cloud storage adapter so media lives in object storage:

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

plugins: [
  s3Storage({
    collections: { media: true },
    bucket: process.env.S3_BUCKET,
    config: { region: process.env.S3_REGION /* credentials */ },
  }),
]

Step 3: build and run

Payload (especially with the Next.js integration) builds like a standard Node/Next app. A representative Dockerfile:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]

The app serves the admin UI, the API, and (if integrated) your frontend from one process — or you can run Payload as a standalone backend and host the frontend separately.

Step 4: migrations

With the SQL adapters, schema changes are managed via Payload's migration tooling. Run migrations as a release step before the new build takes traffic:

npx payload migrate

With MongoDB, the schema is flexible and migrations are less rigid — but you still version your config changes carefully.

Step 5: secure and configure

  • Set access control functions on every collection — Payload denies by default for writes; be explicit about public reads.
  • Configure CORS and CSRF for your frontend origins:
export default buildConfig({
  cors: ['https://myapp.com'],
  csrf: ['https://myapp.com'],
  // ...
});
  • Create the first admin user on initial boot, then lock down registration.

Step 6: consume the API

Payload exposes REST and GraphQL automatically:

# REST
curl "https://cms.example.com/api/posts?where[status][equals]=published"
query { Posts(where: { status: { equals: published } }) { docs { title slug } } }

Common pitfalls

  • PAYLOAD_SECRET changing between deploys. Invalidates sessions/tokens. Pin it.
  • Local file storage. Uploads disappear on restart. Use an object-storage adapter.
  • Skipping migrations on the SQL adapters. Schema drift breaks the API.
  • Over-permissive access control. Define access deliberately per collection.
  • Connection limits. Each replica holds a Postgres pool — size it against your DB plan's limit.

Deploying on PandaStack

Payload is a Node app, which is exactly what PandaStack auto-detects (or it builds your Dockerfile). Provision a managed PostgreSQL and DATABASE_URL is injected automatically into the Postgres adapter — no manual wiring — or use MongoDB if you prefer. Set PAYLOAD_SECRET as a stable environment variable so sessions survive deploys, and offload uploads to object storage to handle the file-persistence gotcha. Run npx payload migrate as part of your build, watch it in live logs, and rely on rollbacks plus managed database backups if a migration misbehaves. Mind connection limits when scaling replicas (50 on Free, 300 on Pro, 1000 on Premium). Custom domains get automatic SSL for the admin and API. The free tier (5 web services + 1 database) hosts a full Payload deployment.

References

  • [Payload CMS documentation](https://payloadcms.com/docs)
  • [Payload database adapters](https://payloadcms.com/docs/database/overview)
  • [Payload cloud storage plugins](https://payloadcms.com/docs/upload/storage-adapters)
  • [Payload access control](https://payloadcms.com/docs/access-control/overview)

---

Payload's code-first model makes content modeling feel like normal development — and deploying it is just deploying a Node app with a database and object storage. PandaStack auto-wires PostgreSQL and builds it from one git push — start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also