How to Self-Host Payload CMS with Docker Compose

What Is Payload CMS?

Payload is a TypeScript-first, open-source headless CMS that installs directly into a Next.js project. Unlike traditional CMSs that run as standalone applications, Payload is a framework — you define content models in TypeScript, and it generates both a REST API and GraphQL API automatically. It includes a polished React-based admin panel, built-in authentication, access control, and content versioning. The project has 40,000+ GitHub stars and ships weekly releases.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended)
  • Docker and Docker Compose installed (guide)
  • 2 GB of RAM minimum (4 GB recommended)
  • 10 GB of free disk space
  • Node.js 20.9+ (for local development and initial project scaffolding)
  • A domain name (optional, for remote access)

Project Setup

Payload requires a Next.js project with a configuration file. Start by creating the project locally:

npx create-payload-app@latest my-project

Choose PostgreSQL as the database adapter when prompted. This generates a project with payload.config.ts, page collections, and a working admin panel.

Docker Compose Configuration

Create a Dockerfile in your project root:

FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN corepack enable pnpm && pnpm build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Create a docker-compose.yml file:

services:
  payload:
    build: .
    container_name: payload
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "3000:3000"
    environment:
      DATABASE_URI: postgres://payload:changeme-strong-password@postgres:5432/payload  # CHANGE password
      PAYLOAD_SECRET: changeme-generate-with-openssl-rand-hex-32                       # CHANGE THIS
      NEXT_PUBLIC_SERVER_URL: http://localhost:3000                                     # CHANGE to your domain
      NODE_ENV: production
    volumes:
      - payload-media:/app/media
    networks:
      - payload-net

  postgres:
    image: postgres:16-alpine
    container_name: payload-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: payload
      POSTGRES_PASSWORD: changeme-strong-password    # Must match DATABASE_URI above
      POSTGRES_DB: payload
    volumes:
      - payload-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U payload"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - payload-net

volumes:
  payload-db:
  payload-media:

networks:
  payload-net:

Create a .env file alongside:

# Required — generate with: openssl rand -hex 32
PAYLOAD_SECRET=your-secret-here

# Database connection
DATABASE_URI=postgres://payload:changeme@postgres:5432/payload

# Public URL (used for admin panel links and API responses)
NEXT_PUBLIC_SERVER_URL=http://localhost:3000

Build and start:

docker compose up -d --build

The first build takes 2–4 minutes (compiling TypeScript and Next.js). Subsequent rebuilds are faster with Docker layer caching.

Initial Setup

  1. Wait for the build to complete: docker compose logs -f payload
  2. Open http://your-server-ip:3000/admin in your browser
  3. Create the first admin account (the registration form appears on first visit)
  4. Start defining collections in payload.config.ts and rebuild

Configuration

Database Adapters

Payload supports three databases. Configure in payload.config.ts:

DatabaseAdapter PackageBest For
PostgreSQL@payloadcms/db-postgresProduction self-hosting (recommended)
MongoDB@payloadcms/db-mongodbDynamic schemas, nested data
SQLite@payloadcms/db-sqliteSingle-server, file-based storage

Key Environment Variables

VariablePurposeRequired
PAYLOAD_SECRETEncryption key for auth tokens and sessionsYes
DATABASE_URIDatabase connection stringYes
NEXT_PUBLIC_SERVER_URLPublic URL for API and admin linksYes
PORTHTTP port (default: 3000)No
NODE_ENVSet to production for optimized buildsRecommended

Content Models

Define collections in payload.config.ts:

import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URI },
  }),
  collections: [
    {
      slug: 'posts',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'content', type: 'richText' },
        { name: 'status', type: 'select', options: ['draft', 'published'] },
      ],
    },
  ],
})

After changing the config, rebuild the Docker image to apply changes.

Reverse Proxy

Place Payload behind a reverse proxy with SSL:

  • Forward Port: 3000
  • WebSocket Support: Enable (Next.js uses WebSockets for hot module replacement in development)

See our Reverse Proxy Setup guide for Nginx Proxy Manager, Traefik, or Caddy configurations.

Backup

Back up these volumes:

VolumeContents
payload-dbPostgreSQL database (all content, users, settings)
payload-mediaUploaded files and images
# Database backup
docker compose exec postgres pg_dump -U payload payload > payload-backup.sql

# Media backup
docker run --rm -v payload-media:/data -v $(pwd):/backup alpine tar czf /backup/payload-media.tar.gz -C /data .

For a comprehensive backup strategy, see our Backup Strategy guide.

Troubleshooting

Build fails with memory errors

Symptom: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory Fix: The Next.js build process is memory-intensive. Set NODE_OPTIONS=--max-old-space-size=4096 in your Dockerfile’s build stage. Ensure the build host has at least 4 GB of available RAM.

Admin panel shows blank page

Symptom: Navigating to /admin shows a white screen or “Application error.” Fix: Check that NEXT_PUBLIC_SERVER_URL matches the URL you’re accessing the site from. If behind a reverse proxy, ensure it passes the correct Host header and supports WebSocket connections.

Database connection refused

Symptom: Error: connect ECONNREFUSED at startup. Fix: Ensure PostgreSQL is healthy before Payload starts. The depends_on with health check in the Compose file should handle this. Verify DATABASE_URI matches the PostgreSQL credentials.

Changes to collections not reflected

Symptom: New fields or collections don’t appear in the admin panel. Fix: Payload generates database migrations at build time. After changing payload.config.ts, you must rebuild the Docker image: docker compose up -d --build.

Resource Requirements

  • RAM: 512 MB idle, 1–2 GB under load (plus PostgreSQL)
  • CPU: Low for content management. Medium during builds.
  • Disk: 500 MB for application, plus media storage

Verdict

Payload CMS is the best headless CMS option for TypeScript developers who want full control over their content model. Its type-safe collections, dual API (REST + GraphQL), and built-in auth system are genuinely excellent. The admin panel is polished and customizable.

The trade-off is complexity. Payload doesn’t have an official Docker image — you build your own. Every content model change requires a rebuild. If you want a headless CMS you can pull and run, Directus or Strapi are simpler to self-host. If you want a traditional CMS, WordPress or Ghost deploy in minutes.

Choose Payload if your team writes TypeScript and wants a CMS that’s a first-class part of the codebase rather than a separate service.

Frequently Asked Questions

How does Payload CMS compare to Strapi?

Both are headless CMSs, but Payload is TypeScript-native and installs directly into your Next.js project — your CMS and frontend share one codebase. Strapi runs as a standalone Node.js service with its own admin panel and plugin ecosystem. Strapi has an official Docker image and simpler initial setup. Payload offers stronger type safety and tighter framework integration. Choose Payload if your team writes TypeScript and wants the CMS embedded in the codebase. Choose Strapi if you want a standalone service with a broader plugin ecosystem.

Does Payload CMS have an official Docker image?

No. Payload installs into your Next.js project, so you build your own Docker image using a standard Node.js Dockerfile. This gives you full control over the build but adds a step compared to CMSs like Directus or Ghost that publish ready-to-use images. Every content model change requires rebuilding the image.

Can I use Payload CMS with MySQL or SQLite?

Payload supports PostgreSQL and MongoDB as database backends. There is no MySQL or SQLite adapter. PostgreSQL is the recommended option for most deployments. If you need MySQL support, Directus or Strapi support multiple database engines.

Does Payload CMS support content versioning?

Yes. Payload has built-in draft and version support at the collection level. You enable it per collection in your payload.config.ts, and Payload tracks all revisions with the ability to restore previous versions. This works for both content entries and global configurations.

How much RAM does Payload CMS need?

Payload runs as a Next.js application, so expect 512 MB idle and 1–2 GB under load plus PostgreSQL overhead. The build process is more memory-intensive than runtime — allocate 2–4 GB during docker compose build. For production serving of content with moderate traffic, 2 GB total (app + database) is a reasonable starting point.

Can I migrate from WordPress to Payload CMS?

There’s no automated migration tool. Payload uses a fundamentally different content model — you define collections in TypeScript rather than using a database-driven schema. Migration requires exporting WordPress content as JSON or CSV and writing import scripts using Payload’s Local API. Media files need to be moved separately. The effort is significant, but the result is a fully typed, version-controlled content system.

Comments