Payload is an open-source, TypeScript-first headless CMS. You describe your content model in code, and Payload generates the admin panel, a REST API, a GraphQL API and a typed Local API from it. Since version 3, Payload runs inside a Next.js application, so the CMS and your site can be deployed as a single app. In this tutorial you will install Payload 3 with PostgreSQL on Ubuntu 24.04, add a posts collection with access control and a hook, apply database migrations, and run it in production under systemd behind Nginx with HTTPS.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS. The Next.js production build is memory hungry; on a 1 GB server, add a swap file first.
- A non-root user with
sudoprivileges. This guide usesyour_userin paths. - Nginx installed, with UFW allowing SSH and the
Nginx Fullprofile. - A subdomain (this guide uses
cms.your_domain) with a DNSArecord pointing to your server.
Step 1 - Installing Node.js and PostgreSQL
Payload 3 requires Node.js 20.9 or later. Ubuntu 24.04 ships Node.js 18, so install the current LTS release from the NodeSource repository. Download the setup script and review it before running it:
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
less /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh
sudo apt install nodejs
Check the versions:
node --version
npm --version
v22.x.x
10.x.x
Install PostgreSQL 16 from the Ubuntu repositories:
sudo apt install postgresql
Create a database role and a database owned by it. Replace your_db_password with a strong password:
sudo -u postgres psql -c "CREATE ROLE payload WITH LOGIN PASSWORD 'your_db_password';"
sudo -u postgres psql -c "CREATE DATABASE payload OWNER payload;"
Verify that the new role can connect over TCP, which is how Payload will connect:
psql "postgresql://payload:[email protected]:5432/payload" -c "SELECT current_user;"
current_user
--------------
payload
(1 row)
Step 2 - Creating the Payload project
create-payload-app scaffolds a Next.js application with Payload already wired in. Run it from your home directory:
cd ~
npx create-payload-app@latest
Answer the prompts as follows:
- Project name:
my-cms - Choose project template:
blank - Select a database:
PostgreSQL - Enter PostgreSQL connection string:
postgresql://payload:[email protected]:5432/payload - Package manager:
npm
The installer creates ~/my-cms, installs dependencies and writes a .env file. Open it to confirm both required variables are set:
cd ~/my-cms
cat .env
DATABASE_URI=postgresql://payload:[email protected]:5432/payload
PAYLOAD_SECRET=3f0c9d...
PAYLOAD_SECRET signs authentication tokens and encrypts sensitive data. Keep it private and never change it on a running site, or every user will be logged out. Protect the file:
chmod 600 .env
The important files in the blank template are src/payload.config.ts (the main configuration) and src/collections/, which already contains a Users collection with authentication enabled and a Media collection for uploads.
Step 3 - Defining a collection with access control and hooks
Collections are the heart of Payload: each one becomes a database table, an admin screen and a set of API endpoints. Create a posts collection:
nano src/collections/Posts.ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'updatedAt'],
},
access: {
// Anonymous visitors only see published posts; logged-in users see everything
read: ({ req: { user } }) => {
if (user) return true
return { status: { equals: 'published' } }
},
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
hooks: {
afterChange: [
async ({ doc, req }) => {
const url = process.env.REVALIDATE_URL
if (!url || doc.status !== 'published') return doc
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: doc.slug }),
})
} catch (err) {
req.payload.logger.error({ err }, 'Revalidation request failed')
}
return doc
},
],
},
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: { position: 'sidebar' },
hooks: {
beforeValidate: [
({ value, data }) =>
value ||
data?.title
?.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, ''),
],
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
{ name: 'coverImage', type: 'upload', relationTo: 'media' },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
What this does:
- Access functions return
true,falseor a query. Returning{ status: { equals: 'published' } }does not just allow or deny; it filters every read, so anonymous API calls, GraphQL queries and Local API calls with access enabled only ever see published documents. - The field hook on
sluggenerates a URL-safe slug from the title when none is given. - The collection hook
afterChangecalls an optional revalidation endpoint (setREVALIDATE_URLin.env) after a published post is saved. Errors are logged instead of thrown, so a frontend outage never blocks editors from saving.
Register the collection in the configuration. Open the file:
nano src/payload.config.ts
Import Posts next to the existing collection imports and add it to the collections array. Leave the rest of the generated file (database adapter, editor, secret, sharp) as it is:
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Posts } from './collections/Posts'
export default buildConfig({
// ...generated options stay unchanged
collections: [Users, Media, Posts],
})
Regenerate the TypeScript types so your editor and the build know about the new collection:
npm run generate:types
The command updates src/payload-types.ts with a Post interface.
Step 4 - Creating and running database migrations
In development (npm run dev), the PostgreSQL adapter pushes schema changes to the database automatically. In production it does not, so you must create migrations and apply them. This also gives you a reviewed, versioned history of every schema change.
Create the first migration from the current configuration:
npx payload migrate:create initial
[INFO]: Migration created at /home/your_user/my-cms/src/migrations/20260925_101500_initial.ts
Apply it:
npx payload migrate
[INFO]: Migrating: 20260925_101500_initial
[INFO]: Migrated: 20260925_101500_initial
[INFO]: Done.
Confirm the tables exist:
psql "postgresql://payload:[email protected]:5432/payload" -c "\dt"
You should see posts, users, media and Payload's internal tables such as payload_migrations. Every time you change a collection, repeat migrate:create with a descriptive name and commit the file in src/migrations to version control.
Step 5 - Building and running Payload with systemd
Create the production build:
npm run build
The build ends with a table of the compiled routes. If it is killed without an error message, the server ran out of memory; add swap and run it again.
Create a systemd unit so the app starts at boot and restarts on failure. The app binds to 127.0.0.1, so only Nginx can reach it:
sudo nano /etc/systemd/system/payload.service
[Unit]
Description=Payload CMS
After=network.target postgresql.service
[Service]
Type=simple
User=your_user
WorkingDirectory=/home/your_user/my-cms
Environment=NODE_ENV=production
ExecStart=/usr/bin/npm run start -- -H 127.0.0.1 -p 3000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Next.js loads .env from the working directory, so the database URI and secret do not need to be repeated here. Start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now payload
sudo systemctl status payload --no-pager
Check that the REST API answers:
curl -s http://127.0.0.1:3000/api/posts | head -c 200; echo
{"docs":[],"hasNextPage":false,"hasPrevPage":false,"limit":10,"nextPage":null,"page":1,"pagingCounter":1,"prevPage":null,"totalDocs":0,"totalPages":1}
Step 6 - Configuring Nginx and HTTPS
Create a server block for the CMS:
sudo nano /etc/nginx/sites-available/payload
server {
listen 80;
listen [::]:80;
server_name cms.your_domain;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:3000;
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";
}
}
Enable it and request a certificate:
sudo ln -s /etc/nginx/sites-available/payload /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d cms.your_domain
Open https://cms.your_domain/admin. On the first visit Payload asks you to create the first user, which becomes the admin account. Create a post from the admin panel and set its status to Published.
Step 7 - Using the REST, GraphQL and Local APIs
Payload generates all APIs from the same collection config, and all of them enforce the access functions you wrote.
As an anonymous client, the REST API only returns published posts:
curl -s "https://cms.your_domain/api/posts?where[status][equals]=published&limit=5&depth=1" | jq '.totalDocs'
1
To act as a user, log in and send the returned token in the Authorization header with the JWT prefix:
TOKEN=$(curl -s -X POST https://cms.your_domain/api/users/login \
-H "Content-Type: application/json" \
-d '{"email":"you@your_domain","password":"your_password"}' | jq -r .token)
curl -s https://cms.your_domain/api/posts -H "Authorization: JWT $TOKEN" | jq '.totalDocs'
Logged in, the count includes drafts as well.
The GraphQL endpoint is /api/graphql, and each collection gets a query named after it:
curl -s -X POST https://cms.your_domain/api/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ Posts(where: { status: { equals: published } }) { totalDocs docs { title slug } } }"}' | jq
Inside the Next.js app itself, use the Local API, which queries the database directly without an HTTP round trip. For example, in a server component under src/app/(frontend):
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function BlogPage() {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
limit: 10,
})
return (
<ul>
{docs.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
ImportantThe Local API skips access control by default because it assumes trusted server code. When you pass a user or act on behalf of a visitor, add
overrideAccess: falseso your access functions apply.
Step 8 - Deploying updates
A typical update after changing code or collections is:
cd ~/my-cms
git pull
npm ci
npx payload migrate
npm run build
sudo systemctl restart payload
Run migrations before the build and restart, so the new code never runs against an old schema. Back up the database first with pg_dump:
pg_dump -Fc "postgresql://payload:[email protected]:5432/payload" > ~/payload-$(date +%F).dump
Uploaded files from the Media collection are stored on disk in the project (in the media directory by default), so include that directory in your file backups too.
Troubleshooting
The service fails with PAYLOAD_SECRET or database errors. Run sudo journalctl -u payload -n 50 --no-pager. Check that .env exists in the WorkingDirectory and that the connection string works with psql.
relation "posts" does not exist in production. The migration was not applied. Run npx payload migrate and restart the service.
The admin panel shows an empty page or import map errors after adding custom components. Run npm run generate:importmap, rebuild and restart.
The build is killed with no message. The kernel's OOM killer stopped it. Add swap or build on a larger machine and deploy the result.
Conclusion
Payload 3 is now running on Ubuntu 24.04 with PostgreSQL, a posts collection filtered by access control, a revalidation hook, versioned migrations and a systemd service behind Nginx with HTTPS. Next, you could enable drafts and versions on the collection with versions: { drafts: true }, store uploads in object storage with one of Payload's storage adapters, or build your public pages in the same Next.js app using the Local API.
