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?
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
| Scenario | Contentful Premium | Sanity Growth/Team | Self-Hosted Payload (VPS) |
|---|---|---|---|
| Monthly cost (base) | $489/mo (Team) | $99/mo (Growth) | EUR 19.99/mo (VPS) |
| Included users/seats | 10 | 20 | Unlimited |
| Extra user cost | ~$50/seat/mo | $15/seat/mo | $0 |
| Content entries | 50k | Unlimited in plan tier | Unlimited |
| API requests | ~2M/mo included | 500k included | Unlimited |
| Locales | Limited per tier | Limited per tier | Unlimited |
| Custom fields/React components | Restricted | Restricted (via extensions) | Full freedom |
| Data ownership | Vendor-hosted | Vendor-hosted | Your VPS, your database |
| GDPR data residency | Pick from vendor regions | Pick from vendor regions | You choose the datacenter |
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:
ssh root@your-server-ipStep 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.
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential curl git ufwPayload 3 requires Node.js 20.x or newer (LTS). Install it from NodeSource:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsVerify:
node --version
npm --versionExpected output:
v20.18.0
10.8.2Install pnpm (Payload's recommended package manager — faster, deterministic, and what the CLI uses by default):
sudo npm install -g pnpm@latest
pnpm --versionCreate a dedicated non-root user for running the app (never run Node production processes as root):
sudo adduser --disabled-password --gecos "" payload
sudo usermod -aG sudo payloadSwitch to that user for the remaining steps:
sudo su - payloadStep 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:
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresqlCreate a database and user for Payload:
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
SQLYour connection string will be:
postgres://payload:[email protected]:5432/payload_cmsTest the connection:
psql "postgres://payload:[email protected]:5432/payload_cms" -c "SELECT version();"Option B: MongoDB
Install MongoDB 7.0 from the official repo:
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \ sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmorecho "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:
sudo systemctl status mongod --no-pagerYour connection string for a local unauthenticated instance will be:
mongodb://127.0.0.1:27017/payload_cmsFor 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:
cd ~
pnpm create payload-appThe 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:
postgresormongodb(match what you installed in Step 2). - Database connection string: Paste the URI from Step 2.
pnpm create payload-app \
--name my-payload-site \
--template website \
--db postgres \
--db-connection-string "postgres://payload:[email protected]:5432/payload_cms"Once complete:
cd my-payload-site
pnpm installStart the dev server to make sure everything boots:
pnpm devExpected output (abbreviated):
▲ 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:
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:
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:
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
userand return either a boolean or a Mongo-style query constraint. Returning{ _status: { equals: 'published' } }means anonymous visitors only see published docs. versions.drafts.autosaveenables Google Docs-style autosaving every 2 seconds while editors work.relationshipanduploadfields create typed foreign keys. Payload generates join queries automatically and exposes populated relations through the REST/GraphQL API.groupfields nest structured data (like SEO metadata) without creating a separate collection.index: trueonslugadds a database index sofindOne({ slug })is O(log n).
Users collection, which powers authentication: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:
pnpm payload generate:typesThis 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:
pnpm add @payloadcms/storage-s3Update src/collections/Media.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:
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:
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.comIf 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:
cd ~/my-payload-site
nano .envPaste and adjust:
# Core
NODE_ENV=production
PAYLOAD_SECRET=<generate-with-openssl-rand-hex-32>
PUBLIC_URL=https://cms.yourdomain.comDatabase
DATABASE_URI=postgres://payload:[email protected]:5432/payload_cmsS3 (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.comNext.js
NEXT_PUBLIC_SERVER_URL=https://cms.yourdomain.comGenerate a strong PAYLOAD_SECRET (this encrypts JWT tokens and cookies):
openssl rand -hex 32Lock down the file:
chmod 600 .envStep 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):
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:
pnpm install --prod=false
pnpm buildExpected output (abbreviated):
▲ 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):
pnpm payload migrateExpected output:
[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:
sudo npm install -g pm2Create ecosystem.config.cjs in the project root:
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):
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/.envCreate the log directory and start PM2:
mkdir -p ~/logs
pm2 start ecosystem.config.cjs
pm2 statusExpected output:
┌────┬──────────────────┬──────┬─────────┬──────────┬────────┐
│ 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:
pm2 save
pm2 startup systemd -u payload --hp /home/payloadPM2 prints a command starting with sudo env PATH=... — run that exact command as root to register the systemd service.
Smoke-test locally:
curl -I http://127.0.0.1:3000Expected:
HTTP/1.1 200 OK
x-powered-by: Next.jsStep 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:
sudo apt install -y nginx certbot python3-certbot-nginxAllow HTTPS and HTTP through the firewall:
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enableCreate /etc/nginx/sites-available/payload:
sudo nano /etc/nginx/sites-available/payloadPaste:
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:
sudo ln -s /etc/nginx/sites-available/payload /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -tObtain a certificate:
sudo certbot --nginx -d cms.yourdomain.comCertbot patches the Nginx config with the real cert paths and installs a renewal timer. Verify:
sudo systemctl list-timers | grep certbotReload 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:
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:
SEED_ADMIN_PASSWORD='generated-password' pnpm tsx src/seed.tsPost-Install: Hardening and Backups
Database backups
For Postgres, schedule a nightly pg_dump to S3:
sudo crontab -u payload -eAdd:
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).dumpFor MongoDB, use mongodump in the same pattern.
Firewall
UFW should already be active from Step 10. Confirm only SSH + Nginx are exposed:
sudo ufw statusExpected:
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
sudo apt install -y fail2ban
sudo systemctl enable --now fail2banRate limiting Payload's auth endpoint in Nginx
Add inside the http {} block of /etc/nginx/nginx.conf:
limit_req_zone $binary_remote_addr zone=payload_auth:10m rate=10r/m;Then in the site config, add to the login endpoint:
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:
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.cjspm2 reload restarts cluster workers one at a time, so no request is dropped.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Error: PAYLOAD_SECRET is required on boot | .env not loaded or secret unset | Ensure .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 login | Database migration not run | pnpm payload migrate (Postgres) or let MongoDB auto-create collections on first write. |
| Uploads succeed but public URL returns 403 | S3 bucket not public-read or wrong S3_PUBLIC_URL | Set 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 API | Next.js calling itself via https:// while certbot not finished | Set NEXT_PUBLIC_SERVER_URL=http://127.0.0.1:3000 for server-side calls, or finish certbot. |
| PM2 workers restart every 60s with OOM | max_memory_restart too low or memory leak | Raise to 1.5G, or run pnpm build with NODE_OPTIONS=--max-old-space-size=4096 if builds OOM. |
ECONNREFUSED 127.0.0.1:5432 | Postgres not started or wrong port | sudo systemctl status postgresql, verify pg_hba.conf allows local connections. |
| Nginx returns 502 Bad Gateway | Next.js not listening on 127.0.0.1:3000 | pm2 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 mistake | Re-run pnpm build and make sure PM2 is running .next/standalone/server.js, not next dev. |
Error: too many connections from Postgres | PM2 cluster × Drizzle pool exceeds Postgres max_connections | Reduce pool.max in the postgres adapter or raise max_connections in postgresql.conf. |
pm2 logs payload-cms --lines 100FAQ
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-seogives every collection a managed SEO group with live previews of Google SERP snippets. - Enable form builder — the
@payloadcms/plugin-form-builderlets editors build contact forms in the admin UI, with submissions stored in aform-submissionscollection 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_restoreto 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/healthroute 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