# 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 migrateWith 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
accessdeliberately 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).