Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Payload Cms Ubuntu
GUIDEInstall Guides

How to Install Payload CMS on Ubuntu 24.04 VPS: TypeScript-Native Headless CMS on Next.js

31 min read

How to Install Payload CMS on Ubuntu 24.04 VPS: TypeScript-Native Headless CMS on Next.js

Payload CMS is the TypeScript-native, code-first headless CMS that ships as a first-class Next.js app. Unlike traditional headless platforms where the admin UI is a separate service you deploy alongside your frontend, Payload 3 mounts directly into the Next.js App Router, shares your database connection, and treats every collection as a typed API endpoint. This tutorial walks you through a full production install on Ubuntu 24.04 — from creating the project with create-payload-app, through configuring Postgres or MongoDB, S3 uploads, access control, and deploying behind PM2 cluster mode with Nginx and automatic TLS.

By the end, you will have a self-hosted Payload CMS running on your own VPS, serving typed REST and GraphQL APIs, an auto-generated admin UI, and file uploads going to S3-compatible object storage — all for the flat monthly cost of a VPS, with no per-seat fees and no entry limits.

Need a VPS for Payload? We recommend CloudCore Professional — 6 vCPU, 12 GB RAM, 100 GB NVMe — plenty of headroom for Next.js SSR, a Postgres database, and PM2 cluster workers on the same box.

Table of Contents

  • What is Payload CMS?
  • Why Self-Host Payload vs. Contentful or Sanity?
  • Prerequisites
  • Step 1: Update Ubuntu and Install Node 20
  • Step 2: Install a Database (Postgres or MongoDB)
  • Step 3: Scaffold with create-payload-app
  • Step 4: Configure payload.config.ts
  • Step 5: Define Collections, Fields, and Access Control
  • Step 6: Add Local and S3 Upload Support
  • Step 7: Environment Variables and Secrets
  • Step 8: Build Next.js in Standalone Mode
  • Step 9: Run Payload Under PM2 Cluster Mode
  • Step 10: Nginx Reverse Proxy + Let's Encrypt TLS
  • Step 11: Seed the First Admin User
  • Post-Install: Hardening and Backups
  • Troubleshooting
  • FAQ
  • Next Steps
  • What is Payload CMS?

    Payload CMS is an open-source, TypeScript-first headless CMS that runs as a Next.js application. Instead of being a separate server you proxy to, Payload 3 mounts into your Next.js App Router at routes like /admin and /api, which means the admin dashboard, the REST and GraphQL APIs, and your public frontend all run inside a single process. You configure everything in payload.config.ts — collections, fields, access control rules, hooks, and plugins — and Payload generates both the database schema and the admin UI from that single source of truth.

    Payload is "code-first" rather than UI-first. You do not click buttons in a dashboard to create content models; you write TypeScript. This makes Payload a natural fit for developers who version-control their content model alongside application code, want full TypeScript type inference across the stack, and need the CMS to extend into custom React components, block editors, server-side hooks, and bespoke admin panels.

    Use cases where Payload shines include marketing sites (blog, landing pages, case studies, docs) powered by a Next.js frontend that hits Payload's local API with zero network hop; e-commerce catalogs where products, variants, and media are defined as typed collections; headless apps that need a content backend for mobile and web clients simultaneously via REST or GraphQL; multi-tenant SaaS where each tenant's content is isolated through access control functions; and internal tools where editors need a polished admin UI but engineers want the flexibility of custom React components in fields.

    Under the hood, Payload uses Drizzle (for Postgres and SQLite adapters) or Mongoose (for MongoDB), and ships with a rich-text Lexical editor, a block-based layout builder, versioning, drafts, scheduled publishing, localization, and a plugin system covering SEO, search, form building, stripe, and more.

    Why Self-Host Payload vs. Contentful or Sanity?

    Cloud headless CMS vendors like Contentful and Sanity are polished, but their pricing models aggressively punish growth. The moment you add another editor, another locale, another environment, or another 10,000 API requests per hour, you cross a paywall. Self-hosting Payload on a VPS flips that economic model — you pay a flat monthly cost for the hardware and get unlimited seats, records, requests, and locales.

    Cost Comparison: Payload vs. Contentful vs. Sanity

    ScenarioContentful PremiumSanity Growth/TeamSelf-Hosted Payload (VPS)
    Monthly cost (base)$489/mo (Team)$99/mo (Growth)EUR 19.99/mo (VPS)
    Included users/seats1020Unlimited
    Extra user cost~$50/seat/mo$15/seat/mo$0
    Content entries50kUnlimited in plan tierUnlimited
    API requests~2M/mo included500k includedUnlimited
    LocalesLimited per tierLimited per tierUnlimited
    Custom fields/React componentsRestrictedRestricted (via extensions)Full freedom
    Data ownershipVendor-hostedVendor-hostedYour VPS, your database
    GDPR data residencyPick from vendor regionsPick from vendor regionsYou choose the datacenter
    For a team of 5 editors pushing a marketing site, a case studies library, and a docs portal, Contentful Team plus seat add-ons easily runs $700-900/month. The same workload on Payload self-hosted fits comfortably on a single CloudCore Professional VPS at EUR 19.99/month — a 95%+ cost reduction — with the added benefit that your content, media, and database live on infrastructure you control.

    Other self-hosting wins:

    • No vendor lock-in. Your content model is TypeScript in your repo. Your data is in your Postgres or MongoDB. You can migrate, fork, or rewrite any of it at any time.
    • Local API performance. When your Next.js frontend and Payload run in the same process, content queries skip the network entirely — they are function calls. Page-render times drop from 200-800ms (remote CMS) to sub-10ms (local API).
    • Private by default. Draft content, editor sessions, and media never traverse a third-party CDN.
    • Custom admin UI without approval. Need a dashboard widget showing live order volume? A custom field that embeds a map? You ship it — no begging a vendor for an extension API.

    Prerequisites

    Before you begin, make sure you have:

    • A VPS running Ubuntu 24.04 LTS with root or sudo access.
    • SSH access to your server.
    • At least 4 GB RAM (8 GB+ recommended for Postgres + Next.js build + PM2 workers).
    • A domain name pointing to your VPS IP (A record) for the TLS step. You can test on the raw IP first.
    • Basic comfort with the command line and editing config files.
    Recommended Plan: CloudCore Professional
    >
    Payload's admin build, Postgres, and PM2 cluster workers all want RAM. For a comfortable development-to-production single-VPS deployment, we recommend CloudCore Professional:
    >
    - 6 vCPU cores
    - 12 GB RAM
    - 100 GB NVMe SSD
    - Unmetered bandwidth
    - EUR 19.99/month

    Connect to your server:

    bash
    ssh root@your-server-ip

    Step 1: Update Ubuntu and Install Node 20

    Start with a fresh package index and upgrade installed packages so security patches and dependency resolution are current.

    bash
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y build-essential curl git ufw

    Payload 3 requires Node.js 20.x or newer (LTS). Install it from NodeSource:

    bash
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt install -y nodejs

    Verify:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.18.0
    10.8.2

    Install pnpm (Payload's recommended package manager — faster, deterministic, and what the CLI uses by default):

    bash
    sudo npm install -g pnpm@latest
    pnpm --version

    Create a dedicated non-root user for running the app (never run Node production processes as root):

    bash
    sudo adduser --disabled-password --gecos "" payload
    sudo usermod -aG sudo payload

    Switch to that user for the remaining steps:

    bash
    sudo su - payload

    Step 2: Install a Database (Postgres or MongoDB)

    Payload ships adapters for Postgres, MongoDB, and SQLite. Postgres is recommended for production — it is transactional, plays well with Drizzle migrations, and is easy to back up. MongoDB is excellent for deeply nested document schemas and flexible content blocks. Pick one path below.

    Option A: Postgres (Recommended)

    Install Postgres 16:

    bash
    sudo apt install -y postgresql postgresql-contrib
    sudo systemctl enable --now postgresql

    Create a database and user for Payload:

    bash
    sudo -u postgres psql <<'SQL'
    CREATE USER payload WITH PASSWORD 'change-me-strong-password';
    CREATE DATABASE payload_cms OWNER payload;
    GRANT ALL PRIVILEGES ON DATABASE payload_cms TO payload;
    \q
    SQL

    Your connection string will be:

    text
    postgres://payload:[email protected]:5432/payload_cms

    Test the connection:

    bash
    psql "postgres://payload:[email protected]:5432/payload_cms" -c "SELECT version();"

    Option B: MongoDB

    Install MongoDB 7.0 from the official repo:

    bash
    curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
      sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor

    echo "deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/7.0 multiverse" | \ sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list

    sudo apt update sudo apt install -y mongodb-org sudo systemctl enable --now mongod

    Confirm it is running:

    bash
    sudo systemctl status mongod --no-pager

    Your connection string for a local unauthenticated instance will be:

    text
    mongodb://127.0.0.1:27017/payload_cms

    For production you should enable authentication — see MongoDB's security checklist.

    Step 3: Scaffold with create-payload-app

    Payload provides an official scaffolding CLI. Still logged in as the payload user:

    bash
    cd ~
    pnpm create payload-app

    The interactive prompt will ask:

    • Project name: my-payload-site
    • Template: Choose one:
    - website — full marketing starter with pages, posts, media, forms, and SEO plugin wired up. Best for most real projects. - blank — minimal skeleton. Best when you already have a Next.js app or want to build a custom content model from scratch. - ecommerce — product catalogs, cart, checkout integrations.
    • Database: postgres or mongodb (match what you installed in Step 2).
    • Database connection string: Paste the URI from Step 2.
    Example (non-interactive) invocation:

    bash
    pnpm create payload-app \
      --name my-payload-site \
      --template website \
      --db postgres \
      --db-connection-string "postgres://payload:[email protected]:5432/payload_cms"

    Once complete:

    bash
    cd my-payload-site
    pnpm install

    Start the dev server to make sure everything boots:

    bash
    pnpm dev

    Expected output (abbreviated):

    text
    ▲ Next.js 15.0.0
      - Local:        http://localhost:3000
      - Ready in 3.4s

    [Payload] Starting Payload... [Payload] Connected to Postgres [Payload] Admin URL: http://localhost:3000/admin

    Stop the dev server with Ctrl+C — we will configure further before building for production.

    Step 4: Configure payload.config.ts

    Open src/payload.config.ts. This is the single source of truth for your Payload instance. A minimal Postgres configuration looks like this:

    ts
    import path from 'path'
    import { fileURLToPath } from 'url'
    import { buildConfig } from 'payload'
    import { postgresAdapter } from '@payloadcms/db-postgres'
    import { lexicalEditor } from '@payloadcms/richtext-lexical'

    import { Users } from './collections/Users' import { Media } from './collections/Media' import { Posts } from './collections/Posts' import { Pages } from './collections/Pages'

    const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename)

    export default buildConfig({ admin: { user: Users.slug, meta: { titleSuffix: '— My Payload Site', }, }, collections: [Users, Media, Posts, Pages], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET || '', typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI || '', }, }), cors: [process.env.PUBLIC_URL || 'http://localhost:3000'], csrf: [process.env.PUBLIC_URL || 'http://localhost:3000'], })

    Swap postgresAdapter for mongooseAdapter from @payloadcms/db-mongodb if you chose MongoDB:

    ts
    import { mongooseAdapter } from '@payloadcms/db-mongodb'

    db: mongooseAdapter({ url: process.env.DATABASE_URI || '', }),

    Step 5: Define Collections, Fields, and Access Control

    A collection in Payload maps to a database table (Postgres) or collection (MongoDB) and to an auto-generated admin UI and API endpoint set. Here is a production-ready Posts collection showing field types, access control, and hooks.

    Create src/collections/Posts.ts:

    ts
    import type { CollectionConfig, Access } from 'payload'

    const isAdmin: Access = ({ req: { user } }) => user?.role === 'admin' const isAdminOrPublished: Access = ({ req: { user } }) => { if (user) return true return { _status: { equals: 'published' }, } }

    export const Posts: CollectionConfig = { slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'slug', '_status', 'publishedAt'], }, versions: { drafts: { autosave: { interval: 2000 }, }, maxPerDoc: 25, }, access: { read: isAdminOrPublished, create: ({ req: { user } }) => Boolean(user), update: ({ req: { user } }) => Boolean(user), delete: isAdmin, }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', required: true, unique: true, index: true, }, { name: 'heroImage', type: 'upload', relationTo: 'media', }, { name: 'content', type: 'richText', }, { name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true, }, { name: 'author', type: 'relationship', relationTo: 'users', required: true, }, { name: 'publishedAt', type: 'date', admin: { position: 'sidebar' }, }, { name: 'seo', type: 'group', fields: [ { name: 'metaTitle', type: 'text' }, { name: 'metaDescription', type: 'textarea' }, ], }, ], }

    Key concepts illustrated:

    • Access functions receive the authenticated user and return either a boolean or a Mongo-style query constraint. Returning { _status: { equals: 'published' } } means anonymous visitors only see published docs.
    • versions.drafts.autosave enables Google Docs-style autosaving every 2 seconds while editors work.
    • relationship and upload fields create typed foreign keys. Payload generates join queries automatically and exposes populated relations through the REST/GraphQL API.
    • group fields nest structured data (like SEO metadata) without creating a separate collection.
    • index: true on slug adds a database index so findOne({ slug }) is O(log n).
    The Users collection, which powers authentication:

    ts
    import type { CollectionConfig } from 'payload'

    export const Users: CollectionConfig = { slug: 'users', auth: { tokenExpiration: 7200, // 2 hours verify: false, maxLoginAttempts: 5, lockTime: 600 * 1000, // 10 minutes cookies: { secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', }, }, admin: { useAsTitle: 'email' }, access: { admin: ({ req: { user } }) => user?.role === 'admin', read: ({ req: { user } }) => Boolean(user), create: ({ req: { user } }) => user?.role === 'admin', update: ({ req: { user }, id }) => user?.role === 'admin' || user?.id === id, delete: ({ req: { user } }) => user?.role === 'admin', }, fields: [ { name: 'name', type: 'text' }, { name: 'role', type: 'select', required: true, defaultValue: 'editor', options: [ { label: 'Admin', value: 'admin' }, { label: 'Editor', value: 'editor' }, ], }, ], }

    When you add auth: {...} to a collection, Payload automatically attaches email, password, and the full JWT-based login/logout/refresh flow. Access functions then gate every CRUD operation across every collection based on the authenticated user.

    Run the Payload type generator so the rest of your Next.js app gets full TypeScript inference:

    bash
    pnpm payload generate:types

    This regenerates src/payload-types.ts from your config.

    Step 6: Add Local and S3 Upload Support

    The Media collection handles file uploads. By default, Payload writes files to a local media/ directory — fine for single-server setups but not horizontally scalable and not CDN-friendly. The @payloadcms/storage-s3 plugin moves uploads to any S3-compatible bucket (AWS S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, MinIO).

    Install the plugin:

    bash
    pnpm add @payloadcms/storage-s3

    Update src/collections/Media.ts:

    ts
    import type { CollectionConfig } from 'payload'

    export const Media: CollectionConfig = { slug: 'media', access: { read: () => true, create: ({ req: { user } }) => Boolean(user), update: ({ req: { user } }) => Boolean(user), delete: ({ req: { user } }) => user?.role === 'admin', }, upload: { staticDir: 'media', mimeTypes: ['image/*', 'application/pdf', 'video/mp4'], imageSizes: [ { name: 'thumbnail', width: 400, height: 300, position: 'centre' }, { name: 'card', width: 768, height: 576, position: 'centre' }, { name: 'hero', width: 1920, height: 1080, position: 'centre' }, ], }, fields: [ { name: 'alt', type: 'text', required: true }, { name: 'caption', type: 'text' }, ], }

    Then wire the S3 plugin in payload.config.ts:

    ts
    import { s3Storage } from '@payloadcms/storage-s3'

    export default buildConfig({ // ...existing config... plugins: [ s3Storage({ collections: { media: { prefix: 'media', generateFileURL: ({ filename, prefix }) => ${process.env.S3_PUBLIC_URL}/${prefix}/${filename}, }, }, bucket: process.env.S3_BUCKET || '', config: { endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || 'auto', credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID || '', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '', }, forcePathStyle: true, }, }), ], })

    For Cloudflare R2 (a cost-effective, egress-free S3-compatible option), your env will look like:

    text
    S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
    S3_REGION=auto
    S3_BUCKET=payload-media
    S3_ACCESS_KEY_ID=...
    S3_SECRET_ACCESS_KEY=...
    S3_PUBLIC_URL=https://media.yourdomain.com

    If you do not want S3 yet, skip the plugin — Payload will store files in ./media relative to the project root. Make sure that directory is writable and included in your backup strategy.

    Step 7: Environment Variables and Secrets

    Create a production .env file in the project root:

    bash
    cd ~/my-payload-site
    nano .env

    Paste and adjust:

    dotenv
    # Core
    NODE_ENV=production
    PAYLOAD_SECRET=<generate-with-openssl-rand-hex-32>
    PUBLIC_URL=https://cms.yourdomain.com

    Database

    DATABASE_URI=postgres://payload:[email protected]:5432/payload_cms

    S3 (optional)

    S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com S3_REGION=auto S3_BUCKET=payload-media S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_PUBLIC_URL=https://media.yourdomain.com

    Next.js

    NEXT_PUBLIC_SERVER_URL=https://cms.yourdomain.com

    Generate a strong PAYLOAD_SECRET (this encrypts JWT tokens and cookies):

    bash
    openssl rand -hex 32

    Lock down the file:

    bash
    chmod 600 .env

    Step 8: Build Next.js in Standalone Mode

    Next.js standalone output produces a self-contained deployment bundle that includes only the Node modules actually used at runtime. This drops image size, startup time, and memory use, and is ideal for PM2.

    Edit next.config.mjs (or next.config.js):

    js
    import { withPayload } from '@payloadcms/next/withPayload'

    /* @type {import('next').NextConfig} / const nextConfig = { output: 'standalone', experimental: { reactCompiler: false, }, images: { remotePatterns: [ { protocol: 'https', hostname: 'media.yourdomain.com' }, ], }, }

    export default withPayload(nextConfig)

    Build production artifacts:

    bash
    pnpm install --prod=false
    pnpm build

    Expected output (abbreviated):

    text
    ▲ Next.js 15.0.0
      Creating an optimized production build ...
      ✓ Compiled successfully
      ✓ Collecting page data
      ✓ Generating static pages (12/12)
      ✓ Finalizing page optimization
      Route (app)                 Size     First Load JS
      ┌ ○ /                       5.1 kB        112 kB
      ├ ○ /admin                 480 kB        580 kB
      ├ ƒ /api/[...payload]      0 B             0 B
      ...

    The first build is slow — it generates the admin UI bundle, runs Drizzle migrations (for Postgres), and type-checks your entire project. Subsequent builds use the Next.js cache at .next/cache.

    Run database migrations explicitly (Postgres adapter only — MongoDB has no schema step):

    bash
    pnpm payload migrate

    Expected output:

    text
    [Payload] Running migrations...
    [Payload] Running 2026_04_16_init
    [Payload] Done.

    Step 9: Run Payload Under PM2 Cluster Mode

    PM2 is the de-facto Node process manager. In cluster mode it forks N worker processes (one per CPU core) behind a shared port, enabling zero-downtime restarts, log rotation, and automatic crash recovery.

    Install PM2 globally:

    bash
    sudo npm install -g pm2

    Create ecosystem.config.cjs in the project root:

    js
    module.exports = {
      apps: [
        {
          name: 'payload-cms',
          script: './.next/standalone/server.js',
          instances: 'max', // one worker per CPU core
          exec_mode: 'cluster',
          env: {
            NODE_ENV: 'production',
            PORT: 3000,
            HOSTNAME: '127.0.0.1',
          },
          max_memory_restart: '1G',
          error_file: '/home/payload/logs/payload-error.log',
          out_file: '/home/payload/logs/payload-out.log',
          merge_logs: true,
          time: true,
        },
      ],
    }

    The standalone build outputs ./.next/standalone/server.js along with a minimal node_modules. Copy the static assets and public folder into the standalone tree (Next.js does not do this automatically):

    bash
    cp -r .next/static .next/standalone/.next/static
    cp -r public .next/standalone/public
    

    Copy media dir if using local uploads

    cp -r media .next/standalone/media

    Copy the .env so the standalone server can read it

    cp .env .next/standalone/.env

    Create the log directory and start PM2:

    bash
    mkdir -p ~/logs
    pm2 start ecosystem.config.cjs
    pm2 status

    Expected output:

    text
    ┌────┬──────────────────┬──────┬─────────┬──────────┬────────┐
    │ id │ name             │ mode │ ↺       │ status   │ memory │
    ├────┼──────────────────┼──────┼─────────┼──────────┼────────┤
    │ 0  │ payload-cms      │ cluster │ 0    │ online   │ 110mb  │
    │ 1  │ payload-cms      │ cluster │ 0    │ online   │ 108mb  │
    │ 2  │ payload-cms      │ cluster │ 0    │ online   │ 112mb  │
    │ 3  │ payload-cms      │ cluster │ 0    │ online   │ 109mb  │
    └────┴──────────────────┴──────┴─────────┴──────────┴────────┘

    Persist across reboots:

    bash
    pm2 save
    pm2 startup systemd -u payload --hp /home/payload

    PM2 prints a command starting with sudo env PATH=... — run that exact command as root to register the systemd service.

    Smoke-test locally:

    bash
    curl -I http://127.0.0.1:3000

    Expected:

    text
    HTTP/1.1 200 OK
    x-powered-by: Next.js

    Step 10: Nginx Reverse Proxy + Let's Encrypt TLS

    Expose Payload on https://cms.yourdomain.com through Nginx, with Let's Encrypt certs renewed automatically.

    Install Nginx and Certbot:

    bash
    sudo apt install -y nginx certbot python3-certbot-nginx

    Allow HTTPS and HTTP through the firewall:

    bash
    sudo ufw allow 'Nginx Full'
    sudo ufw allow OpenSSH
    sudo ufw enable

    Create /etc/nginx/sites-available/payload:

    bash
    sudo nano /etc/nginx/sites-available/payload

    Paste:

    nginx
    upstream payload_upstream {
        server 127.0.0.1:3000;
        keepalive 32;
    }

    server { listen 80; server_name cms.yourdomain.com; return 301 https://$host$request_uri; }

    server { listen 443 ssl http2; server_name cms.yourdomain.com;

    # Certificates populated by certbot ssl_certificate /etc/letsencrypt/live/cms.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/cms.yourdomain.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    client_max_body_size 50m; # allow large media uploads

    location / { proxy_pass http://payload_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";

    proxy_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s; } }

    Enable the site:

    bash
    sudo ln -s /etc/nginx/sites-available/payload /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t

    Obtain a certificate:

    bash
    sudo certbot --nginx -d cms.yourdomain.com

    Certbot patches the Nginx config with the real cert paths and installs a renewal timer. Verify:

    bash
    sudo systemctl list-timers | grep certbot

    Reload Nginx and browse to https://cms.yourdomain.com/admin.

    Step 11: Seed the First Admin User

    On first visit to /admin, Payload detects an empty users table and shows a "Create First User" form. Fill in an email, a strong password, and set role to admin. Submit — you are in.

    Prefer to seed programmatically (useful for CI or fresh deployments)? Create src/seed.ts:

    ts
    import { getPayload } from 'payload'
    import config from './payload.config'

    const run = async () => { const payload = await getPayload({ config }) const existing = await payload.find({ collection: 'users', limit: 1 }) if (existing.totalDocs > 0) { console.log('Users already exist, skipping seed.') process.exit(0) } await payload.create({ collection: 'users', data: { email: '[email protected]', password: process.env.SEED_ADMIN_PASSWORD!, role: 'admin', name: 'Site Admin', }, }) console.log('Seeded admin user.') process.exit(0) }

    run()

    Run it once:

    bash
    SEED_ADMIN_PASSWORD='generated-password' pnpm tsx src/seed.ts

    Post-Install: Hardening and Backups

    Database backups

    For Postgres, schedule a nightly pg_dump to S3:

    bash
    sudo crontab -u payload -e

    Add:

    text
    0 3   * pg_dump --format=custom --no-owner --no-acl "postgres://payload:[email protected]:5432/payload_cms" | aws s3 cp - s3://your-backup-bucket/payload-$(date +\%Y\%m\%d).dump

    For MongoDB, use mongodump in the same pattern.

    Firewall

    UFW should already be active from Step 10. Confirm only SSH + Nginx are exposed:

    bash
    sudo ufw status

    Expected:

    text
    Status: active

    To Action From -- ------ ---- OpenSSH ALLOW Anywhere Nginx Full ALLOW Anywhere

    Port 3000 (Next.js), 5432 (Postgres), and 27017 (MongoDB) should not appear — they are bound to 127.0.0.1 and firewalled.

    Fail2ban for SSH

    bash
    sudo apt install -y fail2ban
    sudo systemctl enable --now fail2ban

    Rate limiting Payload's auth endpoint in Nginx

    Add inside the http {} block of /etc/nginx/nginx.conf:

    nginx
    limit_req_zone $binary_remote_addr zone=payload_auth:10m rate=10r/m;

    Then in the site config, add to the login endpoint:

    nginx
    location /api/users/login {
        limit_req zone=payload_auth burst=5 nodelay;
        proxy_pass http://payload_upstream;
        # ...same proxy headers as before
    }

    This blocks credential-stuffing attacks at the edge.

    Zero-downtime deploys

    On each release:

    bash
    cd ~/my-payload-site
    git pull
    pnpm install
    pnpm build
    cp -r .next/static .next/standalone/.next/static
    cp -r public .next/standalone/public
    cp .env .next/standalone/.env
    pnpm payload migrate
    pm2 reload ecosystem.config.cjs

    pm2 reload restarts cluster workers one at a time, so no request is dropped.

    Troubleshooting

    ProblemCauseSolution
    Error: PAYLOAD_SECRET is required on boot.env not loaded or secret unsetEnsure .env exists next to server.js in .next/standalone/, or set the env var in the PM2 ecosystem file.
    Admin UI returns 500 after first loginDatabase migration not runpnpm payload migrate (Postgres) or let MongoDB auto-create collections on first write.
    Uploads succeed but public URL returns 403S3 bucket not public-read or wrong S3_PUBLIC_URLSet bucket policy to allow s3:GetObject on media/*, or put media behind a CDN domain and update S3_PUBLIC_URL.
    Error: self-signed certificate in certificate chain calling local APINext.js calling itself via https:// while certbot not finishedSet NEXT_PUBLIC_SERVER_URL=http://127.0.0.1:3000 for server-side calls, or finish certbot.
    PM2 workers restart every 60s with OOMmax_memory_restart too low or memory leakRaise to 1.5G, or run pnpm build with NODE_OPTIONS=--max-old-space-size=4096 if builds OOM.
    ECONNREFUSED 127.0.0.1:5432Postgres not started or wrong portsudo systemctl status postgresql, verify pg_hba.conf allows local connections.
    Nginx returns 502 Bad GatewayNext.js not listening on 127.0.0.1:3000pm2 logs payload-cms to see the actual error. Confirm HOSTNAME=127.0.0.1 in ecosystem file.
    Admin UI bundle is huge (>1 MB)Development build deployed by mistakeRe-run pnpm build and make sure PM2 is running .next/standalone/server.js, not next dev.
    Error: too many connections from PostgresPM2 cluster × Drizzle pool exceeds Postgres max_connectionsReduce pool.max in the postgres adapter or raise max_connections in postgresql.conf.
    View real-time logs:

    bash
    pm2 logs payload-cms --lines 100

    FAQ

    Can I run Payload CMS without Next.js?

    No — Payload 3 is architecturally a Next.js app. The admin UI is a Next.js route group, and the REST/GraphQL endpoints are Next.js route handlers. Payload 2 had a standalone Express server, but Payload 3 consolidated on Next.js to give you one build, one deploy target, and zero-hop local APIs. If you need a separate frontend framework (Astro, Remix, SvelteKit), run Payload on one domain and call its REST/GraphQL API from your frontend — both sides still get the full type-safe experience via generated types.

    Which database adapter should I pick — Postgres or MongoDB?

    Choose Postgres if you want transactional integrity, strict schemas, easy SQL-level backups, and mature tooling (pgAdmin, pgBackRest, PITR with WAL archiving). Drizzle migrations give you versioned, reviewable schema changes. Most production Payload deployments we see are Postgres. Choose MongoDB if your content model is deeply nested, you want schemaless flexibility for blocks and variants, or you already run a MongoDB cluster. Performance is comparable for typical CMS workloads. SQLite is great for single-user or preview deployments but not recommended under PM2 cluster mode (file-level locking).

    How does Payload compare to Strapi and Directus?

    Strapi is UI-first: you click to build content types, and Strapi generates the schema. It is approachable for non-developers but harder to version-control (config/*.json files are generated, not authored). Directus is database-first: you point it at an existing Postgres/MySQL database and it exposes whatever tables are there via REST/GraphQL. Great for adding a CMS layer to a legacy database. Payload is code-first and TypeScript-native: you write your content model in .ts, get end-to-end type inference, and get tight Next.js integration. If you already have a Next.js frontend and a TypeScript team, Payload offers the lowest friction. If you need the CMS and frontend to be separate concerns owned by different teams, all three work — see /kb/install-guides/how-to-install-strapi-ubuntu and /kb/install-guides/how-to-install-directus-ubuntu for their install guides.

    Do I need a separate VPS for the database?

    Not for most deployments. A single CloudCore Professional VPS (6 vCPU, 12 GB RAM) comfortably runs Payload, Postgres, PM2 cluster workers, and Nginx. Split the database onto its own box when you exceed ~50 GB of data, need multi-app access to the same database, or need HA replication. Until then, keeping everything on one VPS gives you lower latency (local socket instead of network) and simpler backups.

    How do I handle media at scale?

    Local disk works up to a few GB of media. Beyond that, use the @payloadcms/storage-s3 plugin with a CDN-fronted bucket (Cloudflare R2 + Cloudflare CDN is a popular zero-egress combination). Payload's imageSizes config auto-generates resized variants (thumbnail, card, hero) on upload, and the S3 plugin uploads every variant. Point next/image at the CDN domain in next.config.mjs remotePatterns and you get responsive, cached images for free.

    Can I run Payload in a Docker container?

    Yes. Payload ships a Dockerfile template with the website starter. The standalone Next.js output is container-friendly — a typical final image is 150-250 MB. For single-VPS deployments PM2 is simpler; for Kubernetes or Coolify, Docker is the right call. See our /kb/install-guides/how-to-install-nextjs-ubuntu guide for container patterns that apply directly.

    How do I migrate off Contentful or Sanity to Payload?

    Three steps: (1) define your Payload collections to match the Contentful content types / Sanity schemas; (2) write a one-off import script using payload.create() that reads from Contentful's Management API or Sanity's export, transforms fields, and inserts into Payload; (3) rewrite your frontend fetches to use Payload's local API or REST endpoint. Rich-text migration is the trickiest part — Contentful uses its own JSON format, Sanity uses Portable Text, Payload uses Lexical. Write a converter function and test it on a representative sample before bulk importing.

    Next Steps

    You now have Payload CMS running in production on Ubuntu 24.04 with Postgres or MongoDB, S3-backed media, PM2 cluster mode, and TLS via Let's Encrypt. Recommended follow-ups:

    • Add the SEO plugin — pnpm add @payloadcms/plugin-seo gives every collection a managed SEO group with live previews of Google SERP snippets.
    • Enable form builder — the @payloadcms/plugin-form-builder lets editors build contact forms in the admin UI, with submissions stored in a form-submissions collection and email notifications wired up.
    • Wire up preview mode — Next.js draft mode + Payload's versions/drafts lets editors click "Preview" in the admin and see unpublished content on the live frontend with one token-signed cookie.
    • Set up a staging environment — replicate the install on a second VPS, use pg_dump | pg_restore to copy production content, and push feature branches there for QA before deploying to prod.
    • Add observability — point PM2 at Sentry, tail Nginx logs into Grafana Loki, and add a /api/health route Payload already exposes to your uptime monitor.
    • Read the official docs — the Payload CMS documentation covers every plugin, hook, and field type in depth, and the Payload team is active on GitHub and Discord.
    • Explore related stacks — if you want a Next.js frontend on a separate box, see /kb/install-guides/how-to-install-nextjs-ubuntu. If you are comparing CMS options, the /kb/install-guides/how-to-install-strapi-ubuntu and /kb/install-guides/how-to-install-directus-ubuntu guides walk through the two strongest Payload alternatives.

    Need a VPS built for Payload?
    >
    Our CloudCore Professional plan (6 vCPU, 12 GB RAM, 100 GB NVMe, unmetered bandwidth — EUR 19.99/month) is sized exactly for a single-VPS Payload + Postgres + PM2 cluster deployment. Deploy Ubuntu 24.04 in 60 seconds, SSH in, and follow this guide end to end.
    >
    Launch a CloudCore Professional VPS

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket