Tutorial

Deploy Payload CMS on a VPS: complete guide

Development10 min read6 steps

Payload CMS 3 installs directly inside a Next.js application, making it one of the most code-native headless CMS options available. This guide covers the full deployment on a VPS: Docker Compose with healthchecks, user and API key management, collection hooks, migration workflow, and troubleshooting the most common production errors.

Contents· Why self-host Payload CMS on a VPS1/10
  1. 01Why self-host Payload CMS on a VPS
  2. 02Concrete benefits
  3. 03Hardware and software requirements
  4. 04Step-by-step deployment
  5. 05Authentication and access management
  6. 06Collection hooks: reacting to content events
  7. 07Updates and schema migrations
  8. 08When `sharp` fails to load
  9. 09Troubleshooting: common production errors
  10. 10Payload CMS vs Strapi: which headless CMS to self-host?

Why self-host Payload CMS on a VPS

Payload CMS takes a radically code-first approach: your content schema is defined in TypeScript configuration files, versioned with Git, and since version 3 Payload installs directly inside a Next.js application via the App Router. This means a VPS lets you host both the CMS and the front-end in a single Node process, sharing the same build and runtime. You get end-to-end typing, schema migrations managed in code, and no dependency on a GUI to model your content. Self-hosting is the natural choice here: Payload is designed to be deployed like any Next.js app, and a VPS gives you full control over the database (MongoDB or PostgreSQL), uploads, and environment variables, without any intermediary platform.

Concrete benefits

  • Content schema defined in TypeScript and versioned with Git: full code review and history
  • CMS and Next.js front in a single runtime: one build, one process to deploy
  • End-to-end typing between Payload config, API and front, no manual generation
  • Database choice: MongoDB or PostgreSQL via official adapters
  • Code-driven schema migrations (payload migrate), reproducible across environments
  • Local API: direct data access without HTTP calls from Next.js server code
  • No licensing cost: Payload CMS 3 is open-source (MIT) — you only pay for the VPS

Hardware and software requirements

Since Payload runs on Next.js, the build is demanding: plan for a VPS with 2 vCPU and 4 GB RAM to build and run the application comfortably, especially if the Next.js front is large. Install Node.js 20 LTS, Docker and Docker Compose. On the database side, provision MongoDB 7 or PostgreSQL 16 depending on your chosen adapter (@payloadcms/db-mongodb or @payloadcms/db-postgres). Point your domain to the VPS IP. Budget 15 GB of disk space for node_modules, the .next build, uploads and database backups.

Step-by-step deployment

  1. Choose and configure the database adapter

    In payload.config.ts, declare the adapter: mongooseAdapter for MongoDB or postgresAdapter for PostgreSQL, reading the connection URL from DATABASE_URI. This is a structural choice: PostgreSQL requires migrations, MongoDB is more flexible on schema.

  2. Prepare environment variables

    Create a .env.production file with at minimum:

    PAYLOAD_SECRET=your-secret-32-characters-minimum
    DATABASE_URI=postgresql://user:pass@postgres:5432/payload
    NEXT_PUBLIC_SERVER_URL=https://example.com
    NODE_ENV=production
    PAYLOAD_CONFIG_PATH=src/payload.config.ts

    PAYLOAD_SECRET encrypts JWT tokens and session cookies — a short or predictable value weakens the entire authentication system. Generate it with openssl rand -hex 32. NEXT_PUBLIC_SERVER_URL must match the final public URL: Payload uses it to build media links and email URLs.

  3. Write Docker Compose with healthchecks

    A robust docker-compose.yml includes healthchecks to ensure the app only starts after the database is ready:

    services:
      app:
        build: .
        ports:
          - "127.0.0.1:3000:3000"
        env_file: .env.production
        depends_on:
          postgres:
            condition: service_healthy
        volumes:
          - uploads:/app/public/media
    
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_USER: payload
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
          POSTGRES_DB: payload
        volumes:
          - pgdata:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U payload"]
          interval: 5s
          timeout: 5s
          retries: 10
    
    volumes:
      pgdata:
      uploads:

    Without the healthcheck and service_healthy condition, the app starts before PostgreSQL accepts connections, the connection pool fails and the container restart-loops.

  4. Build the image and run migrations

    Build then run migrations in this order:

    docker compose build
    docker compose up -d postgres
    docker compose run --rm app npx payload migrate
    docker compose up -d app

    For PostgreSQL, npx payload migrate applies migration files generated in src/migrations/. If no files exist yet, generate them first with npx payload migrate:create. For MongoDB, the schema is applied automatically on first start — the migrate command has no effect.

  5. Configure Nginx as a reverse proxy

    Point a vhost to http://127.0.0.1:3000 (default Next.js port). Increase client_max_body_size for media uploads and add X-Forwarded-Proto headers so Payload generates correct HTTPS URLs.

  6. Secure with SSL and set the server URL

    Run certbot --nginx -d example.com, then verify that serverURL: 'https://example.com' is set in payload.config.ts. This URL drives media links, password reset emails and correct admin panel operation behind the proxy.

Authentication and access management

Payload 3 manages users through the users collection defined in payload.config.ts. Enable authentication on the collection with auth: true — this automatically adds the /api/users/login, /api/users/logout, /api/users/me and /api/users/refresh-token endpoints.

For roles, Payload does not impose a model: define a role field (type select) on the users collection, then drive access via access functions at the collection and operation level:

access: {
  read: ({ req: { user } }) => user?.role === 'admin',
  create: isAdmin,
  update: isAdminOrSelf,
  delete: isAdmin,
}

For programmatic access (CI, third-party integrations), use API keys: enable useAPIKey: true in the collection's auth config. Each user can then generate a key from the admin panel. Pass it in the Authorization: users API-Key your-key header. API keys are hashed in the database — a lost key cannot be recovered, it must be regenerated.

Collection hooks: reacting to content events

Payload 3's collection hooks let you run code before or after each CRUD operation. They replace webhooks cleanly when logic lives in the same runtime:

hooks: {
  afterChange: [
    async ({ doc, operation }) => {
      if (operation === 'create') {
        await notifySubscribers(doc)
      }
    },
  ],
  beforeDelete: [
    async ({ id }) => {
      await cleanupMedia(id)
    },
  ],
}

Available hooks are beforeOperation, beforeValidate, beforeChange, afterChange, beforeRead, afterRead, beforeDelete and afterDelete. An afterChange hook is ideal for invalidating a CDN cache, sending a notification, or syncing with an external service after publication.

For outbound HTTP webhooks (notifying a static site, Slack, or a build pipeline), Payload has no dedicated module but afterChange hooks suffice: a simple fetch to your target endpoint inside the hook covers the use case. Avoid blocking the response on an external request — wrap the call in setImmediate or a background process if latency matters.

Updates and schema migrations

Payload 3 follows semantic versioning. Patch updates (3.x.y → 3.x.z) are safe to apply without migrations. Minor updates (3.x → 3.y) may add columns or indexes and require running payload migrate.

Recommended workflow for each update:

# 1. Bump the version in package.json
npm install [email protected] @payloadcms/[email protected]

# 2. Generate a migration file if the schema changed
npx payload migrate:create

# 3. Test locally on a database copy
npx payload migrate

# 4. Commit the migration file with the version bump
git add src/migrations/ package.json package-lock.json
git commit -m "chore: payload 3.88.0"

# 5. In production: stop app, apply migrations, restart
docker compose run --rm app npx payload migrate
docker compose up -d app

Never manually edit generated files in src/migrations/: Payload verifies them by hash. A modified file causes migrate to fail with Error: Migration file has been modified since it was created. To roll back a migration: npx payload migrate:down.

When `sharp` fails to load

This is the most common deployment obstacle on a VPS, and it takes three distinct forms. The most confusing is Unsupported CPU: prebuilt binaries for linux-x64 require v2 microarchitecture: it comes from the machine's processor, not your code or dependencies. Pre-compiled binaries target an instruction set that older CPUs do not expose — this is a VPS selection criterion, not a bug to fix. The second, Could not load the "sharp" module using the linux-x64 runtime, almost always indicates a node_modules built on a different platform, typically a macOS development machine copied to a Linux server: reinstall on the target machine rather than copying the folder. The third is specific to multi-stage Docker: sharp installed in the build stage only, absent from the runtime stage — the container starts then fails on the first image operation. Install it explicitly in the final stage. On ARM architecture, the same vigilance applies: the library must be resolved for that platform.

Troubleshooting: common production errors

Build OOM — Killed or JavaScript heap out of memory.
Occurs during npm run build when available RAM is below 3 GB. Node.js is limited to ~1.8 GB by default. Set NODE_OPTIONS=--max-old-space-size=3072 in the environment before the build, or build the image on a more powerful machine and push to a registry.

PostgreSQL — Error: connect ECONNREFUSED 127.0.0.1:5432.
The app tries to connect before PostgreSQL is ready, or the connection URL points to localhost instead of the Docker service name (postgres). Verify that DATABASE_URI uses the service name (postgresql://payload:pass@postgres:5432/payload) and that the healthcheck is in place (see step 3).

Admin inaccessible in prod — /admin returns 404 or redirect loop.
Two main causes: serverURL not set or not matching the real URL (Payload generates its internal redirects from this value), or the X-Forwarded-Proto: https header missing from the Nginx reverse proxy. Add proxy_set_header X-Forwarded-Proto $scheme; to the vhost and verify serverURL matches the public origin exactly, without a trailing slash.

CORS — Access-Control-Allow-Origin missing on the API.
Payload reads cors from payload.config.ts. In production, list your allowed origins explicitly: cors: { origins: ['https://example.com'] }. Leaving cors: '*' accepts all origins, which may be appropriate for a public API but exposes the admin to cross-origin requests. cors: { origins: [serverURL] } is the minimal safe setting.

Error: Migration file has been modified since it was created.
A file in src/migrations/ was manually edited after generation. Payload verifies the integrity of each file. Restore the original from Git, generate a new migration file if the schema changed, and reapply in order.

Payload CMS vs Strapi: which headless CMS to self-host?

Scroll the table

CriterionPayload CMSStrapi
Configuration approachCode-first in TypeScript, versioned with GitGUI-first via Content-Type Builder
Front integrationNative in Next.js (single runtime)Decoupled, separate front and CMS
Supported databasesMongoDB and PostgreSQLPostgreSQL, MySQL, SQLite
TypingNative end-to-end TypeScriptGenerated types, looser integration
Server-side data accessLocal API without HTTP callsREST/GraphQL API over HTTP
Content modelingIn code, by developersIn the interface, accessible to non-devs
RAM required for buildHigh (Next.js build)High (React admin build)
Migration managementVersioned files, `payload migrate`Auto migrations via Strapi CLI
Native API keysYes, per collection with hashingYes, via API tokens
Collection hooksNative, TypeScript-typedLifecycle hooks via module middleware
Ideal forDev teams, typed Next.js projectsMixed teams, visual modeling

Use Payload's Local API in your Next.js server components: instead of calling your own API via fetch, import getPayload and query the database directly (payload.find({ collection: 'posts' })). You eliminate an HTTP round-trip and gain both in latency and security. For media, plug in the @payloadcms/storage-s3 plugin to store uploads outside the VPS, and automate a daily database dump via a cron job for off-server backups. Finally, enable Payload's native draft/preview workflow to offer editors a preview step before publishing, without any third-party plugin.

Deploy Payload CMS on a unified stack

The ServOrbit Cloud VPS offers the power needed for Payload's Next.js build and a Docker environment ready for MongoDB or PostgreSQL, to host your code-first CMS and your front end on the same machine.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab