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.

Comments