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
Choose and configure the database adapter
In
payload.config.ts, declare the adapter:mongooseAdapterfor MongoDB orpostgresAdapterfor PostgreSQL, reading the connection URL fromDATABASE_URI. This is a structural choice: PostgreSQL requires migrations, MongoDB is more flexible on schema.Prepare environment variables
Create a
.env.productionfile 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.tsPAYLOAD_SECRETencrypts JWT tokens and session cookies — a short or predictable value weakens the entire authentication system. Generate it withopenssl rand -hex 32.NEXT_PUBLIC_SERVER_URLmust match the final public URL: Payload uses it to build media links and email URLs.Write Docker Compose with healthchecks
A robust
docker-compose.ymlincludes 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_healthycondition, the app starts before PostgreSQL accepts connections, the connection pool fails and the container restart-loops.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 appFor PostgreSQL,
npx payload migrateapplies migration files generated insrc/migrations/. If no files exist yet, generate them first withnpx payload migrate:create. For MongoDB, the schema is applied automatically on first start — themigratecommand has no effect.Configure Nginx as a reverse proxy
Point a vhost to
http://127.0.0.1:3000(default Next.js port). Increaseclient_max_body_sizefor media uploads and addX-Forwarded-Protoheaders so Payload generates correct HTTPS URLs.Secure with SSL and set the server URL
Run
certbot --nginx -d example.com, then verify thatserverURL: 'https://example.com'is set inpayload.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 appNever 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
| Criterion | Payload CMS | Strapi |
|---|---|---|
| Configuration approach | Code-first in TypeScript, versioned with Git | GUI-first via Content-Type Builder |
| Front integration | Native in Next.js (single runtime) | Decoupled, separate front and CMS |
| Supported databases | MongoDB and PostgreSQL | PostgreSQL, MySQL, SQLite |
| Typing | Native end-to-end TypeScript | Generated types, looser integration |
| Server-side data access | Local API without HTTP calls | REST/GraphQL API over HTTP |
| Content modeling | In code, by developers | In the interface, accessible to non-devs |
| RAM required for build | High (Next.js build) | High (React admin build) |
| Migration management | Versioned files, `payload migrate` | Auto migrations via Strapi CLI |
| Native API keys | Yes, per collection with hashing | Yes, via API tokens |
| Collection hooks | Native, TypeScript-typed | Lifecycle hooks via module middleware |
| Ideal for | Dev teams, typed Next.js projects | Mixed 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.