Back to Blog
Tutorial10 min read2026-07-01

How to Deploy Payload CMS with PostgreSQL

Payload CMS runs as a Node app on top of Next.js with your choice of database. This guide covers deploying Payload with the PostgreSQL adapter, handling migrations, and configuring media storage for production.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Payload is a code-first, TypeScript-native headless CMS. Since version 3, it runs natively inside Next.js, which changes how you deploy it: you are shipping a Next.js app that happens to expose an admin panel and a REST/GraphQL API. With the PostgreSQL adapter, it slots neatly into a standard container deploy.

Choose the Postgres adapter

Payload supports MongoDB and SQL databases through swappable adapters. For Postgres:

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

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET,
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL }
  }),
  collections: [
    {
      slug: 'posts',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'content', type: 'richText' }
      ]
    }
  ]
});

The PAYLOAD_SECRET signs JWTs and encrypts sensitive fields. Generate a strong one and never reuse it across environments:

openssl rand -base64 32

Migrations: the part people skip

Payload's Postgres adapter generates real SQL migrations. In development it can push schema changes automatically, but in production you want deterministic, reviewable migrations.

# Generate a migration from your current config
npx payload migrate:create

# Apply pending migrations
npx payload migrate

The golden rule: run payload migrate as a deploy step before the new app serves traffic. Set push: false on the adapter in production so it never silently alters tables:

postgresAdapter({
  pool: { connectionString: process.env.DATABASE_URL },
  push: process.env.NODE_ENV !== 'production'
});

Media storage

Payload uploads default to the local filesystem. In a containerized world that disk is ephemeral — every restart wipes uploaded files. Use a cloud storage adapter:

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

// inside buildConfig plugins:
s3Storage({
  collections: { media: true },
  bucket: process.env.S3_BUCKET,
  config: {
    region: process.env.S3_REGION,
    credentials: {
      accessKeyId: process.env.S3_KEY,
      secretAccessKey: process.env.S3_SECRET
    }
  }
})

Any S3-compatible store works — AWS S3, Cloudflare R2, MinIO. This is non-negotiable for production; skipping it is the most common reason "my images disappeared."

Build and run

Because Payload 3 is a Next.js app, the production commands are the Next.js ones:

npm run build   # next build
npm start       # next start, defaults to PORT 3000
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
COPY --from=build /app ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]

Deploying on PandaStack

  1. 1Provision a managed PostgreSQL (14.x or 16.x). PandaStack injects DATABASE_URL automatically.
  2. 2Connect the Git repo as a container app. The Dockerfile is auto-detected; if you skip the Dockerfile, the Node buildpack handles next build/next start.
  3. 3Set PAYLOAD_SECRET, the S3 credentials, and NODE_ENV=production as environment variables.
  4. 4Run npx payload migrate once before exposing traffic.
  5. 5Add your domain; SSL is automatic.

The admin panel lives at /admin, the REST API at /api, and GraphQL at /api/graphql. Create your first admin user by visiting /admin after the first successful deploy.

Production checklist

  • [ ] push: false in the Postgres adapter for production.
  • [ ] Migrations run as an explicit deploy step.
  • [ ] Cloud storage adapter configured (no local uploads).
  • [ ] PAYLOAD_SECRET set and unique per environment.
  • [ ] serverURL/CORS configured if your frontend is on a different origin.
  • [ ] Connection pool sized for your instance (start small, 10–20).

Verifying

# API should respond
curl -s https://cms.example.com/api/posts | head

# Admin panel loads
curl -s -o /dev/null -w '%{http_code}' https://cms.example.com/admin

A reachable /api and a 200 on /admin mean your database connection and build are healthy.

References

  • [Payload PostgreSQL adapter docs](https://payloadcms.com/docs/database/postgres)
  • [Payload migrations guide](https://payloadcms.com/docs/database/migrations)
  • [Payload S3 storage plugin](https://payloadcms.com/docs/upload/storage-adapters)
  • [Payload production deployment](https://payloadcms.com/docs/production/deployment)

---

Payload is a clean fit for PandaStack: container deploy plus a managed PostgreSQL with DATABASE_URL injected automatically. Try it 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