Payload es un headless CMS de código abierto escrito en TypeScript que se instala dentro de una aplicación Next.js: el panel de administración, la API REST, la API GraphQL y tu frontend comparten el mismo proyecto. Toda la configuración (colecciones, campos, permisos, hooks) se escribe en código, por lo que se versiona con Git como el resto de la aplicación. En este tutorial instalarás Payload 3 con PostgreSQL en Ubuntu 24.04, crearás una colección de artículos con control de acceso por roles y lo pondrás en producción con systemd, Nginx y HTTPS.

Requisitos previos

  • Un servidor con Ubuntu 24.04 LTS y al menos 2 GB de RAM, por ejemplo un VPS de CubePath. La compilación de Next.js consume bastante memoria; con 1 GB necesitarás swap.
  • Un usuario no root con privilegios sudo.
  • Un dominio, por ejemplo cms.your_domain, con un registro DNS A apuntando a your_server_ip.
  • Nginx y Certbot instalados (sudo apt install nginx certbot python3-certbot-nginx).

Sustituye your_domain, your_server_ip y your_user por tus valores a lo largo de la guía.

Paso 1: Instalar Node.js 22

Payload 3 necesita Node.js 20.9 o superior, y Ubuntu 24.04 incluye Node.js 18. Instala la versión LTS 22 desde el repositorio de NodeSource. Descarga primero su script de configuración, revísalo y ejecútalo:

curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
less nodesource_setup.sh
sudo bash nodesource_setup.sh

El script añade el repositorio con su clave en /etc/apt/keyrings. Instala Node.js:

sudo apt install -y nodejs

Comprueba las versiones:

node --version
npm --version
v22.x.x
10.x.x

Paso 2: Crear la base de datos PostgreSQL

Payload admite MongoDB, PostgreSQL y SQLite. PostgreSQL está en los repositorios de Ubuntu y es una buena opción por defecto:

sudo apt install -y postgresql

Crea un usuario y una base de datos para Payload. Sustituye your_strong_password por una contraseña aleatoria (puedes generarla con openssl rand -hex 24):

sudo -u postgres psql -c "CREATE USER payload WITH PASSWORD 'your_strong_password';"
sudo -u postgres psql -c "CREATE DATABASE payload OWNER payload;"

Verifica que puedes conectarte con esas credenciales:

psql "postgresql://payload:[email protected]:5432/payload" -c "SELECT current_user;"
 current_user
--------------
 payload
(1 row)

Paso 3: Crear el proyecto de Payload

Crea el directorio de la aplicación y asígnalo a tu usuario:

sudo mkdir -p /var/www/payload
sudo chown your_user:your_user /var/www/payload
cd /var/www/payload

Genera el proyecto con el asistente oficial:

npx create-payload-app@latest

Responde a las preguntas así:

  • Project name: cms.
  • Template: blank, una plantilla mínima con las colecciones users y media.
  • Database: PostgreSQL.
  • Connection string: postgresql://payload:[email protected]:5432/payload.

El asistente instala las dependencias y escribe un archivo .env con la cadena de conexión y un PAYLOAD_SECRET aleatorio, la clave con la que Payload firma los tokens. Entra en el proyecto y revisa la estructura:

cd /var/www/payload/cms
ls src src/collections
src:
app  collections  payload-types.ts  payload.config.ts

src/collections:
Media.ts  Users.ts

src/payload.config.ts es la configuración principal, src/collections/ contiene una definición por colección y src/app/(payload) monta el panel dentro de Next.js. Arranca el modo de desarrollo para comprobar que todo funciona:

npm run dev
  ▲ Next.js 15.x.x
  - Local:        http://localhost:3000
 ✓ Ready in 3.2s

Desde tu equipo, abre un túnel SSH para ver el panel sin exponer el puerto 3000:

ssh -L 3000:localhost:3000 your_user@your_server_ip

Abre http://localhost:3000/admin en el navegador y crea el primer usuario administrador. Después detén el servidor de desarrollo con Ctrl+C.

Paso 4: Añadir roles a los usuarios

Payload no incluye roles por defecto: se añaden como un campo más de la colección de usuarios y luego se consultan en las funciones de acceso. Abre la colección:

nano src/collections/Users.ts

Sustituye su contenido por lo siguiente:

import type { CollectionConfig } from 'payload'

export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  admin: {
    useAsTitle: 'email',
  },
  access: {
    create: ({ req: { user } }) => user?.rol === 'admin',
    read: ({ req: { user } }) => {
      if (!user) return false
      if (user.rol === 'admin') return true
      return { id: { equals: user.id } }
    },
    update: ({ req: { user } }) => {
      if (!user) return false
      if (user.rol === 'admin') return true
      return { id: { equals: user.id } }
    },
    delete: ({ req: { user } }) => user?.rol === 'admin',
  },
  fields: [
    {
      name: 'nombre',
      type: 'text',
    },
    {
      name: 'rol',
      type: 'select',
      required: true,
      defaultValue: 'editor',
      saveToJWT: true,
      options: [
        { label: 'Administrador', value: 'admin' },
        { label: 'Editor', value: 'editor' },
      ],
      access: {
        update: ({ req: { user } }) => user?.rol === 'admin',
      },
    },
  ],
}

Una función de acceso puede devolver true, false o una consulta. Cuando devuelve una consulta como { id: { equals: user.id } }, Payload la añade a la búsqueda en la base de datos, de modo que un editor solo ve y edita su propio perfil. saveToJWT incluye el rol en el token para no consultar la base de datos en cada petición, y el acceso a nivel de campo impide que un editor se ascienda a administrador.

Paso 5: Crear la colección de artículos

Crea el archivo de la colección:

nano src/collections/Posts.ts
import type { CollectionConfig } from 'payload'

function slugify(texto: string): string {
  return texto
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

export const Posts: CollectionConfig = {
  slug: 'posts',
  labels: { singular: 'Artículo', plural: 'Artículos' },
  admin: {
    useAsTitle: 'titulo',
    defaultColumns: ['titulo', 'estado', 'autor', 'updatedAt'],
  },
  access: {
    create: ({ req: { user } }) => Boolean(user),
    read: ({ req: { user } }) => {
      if (user) return true
      return { estado: { equals: 'publicado' } }
    },
    update: ({ req: { user } }) => {
      if (!user) return false
      if (user.rol === 'admin') return true
      return { autor: { equals: user.id } }
    },
    delete: ({ req: { user } }) => user?.rol === 'admin',
  },
  hooks: {
    beforeChange: [
      ({ data, req, operation }) => {
        if (operation === 'create' && req.user && !data.autor) {
          data.autor = req.user.id
        }
        if (data.titulo && !data.slug) {
          data.slug = slugify(data.titulo)
        }
        return data
      },
    ],
    afterChange: [
      ({ doc, previousDoc, req }) => {
        if (doc.estado === 'publicado' && previousDoc?.estado !== 'publicado') {
          req.payload.logger.info(`Artículo publicado: ${doc.slug}`)
        }
      },
    ],
  },
  fields: [
    { name: 'titulo', type: 'text', required: true },
    {
      name: 'slug',
      type: 'text',
      unique: true,
      index: true,
      admin: { position: 'sidebar' },
    },
    { name: 'contenido', type: 'richText' },
    { name: 'imagen', type: 'upload', relationTo: 'media' },
    {
      name: 'autor',
      type: 'relationship',
      relationTo: 'users',
      admin: { position: 'sidebar' },
    },
    {
      name: 'estado',
      type: 'select',
      required: true,
      defaultValue: 'borrador',
      options: [
        { label: 'Borrador', value: 'borrador' },
        { label: 'Publicado', value: 'publicado' },
      ],
      admin: { position: 'sidebar' },
    },
  ],
}

La colección combina tres mecanismos:

  • Acceso: los visitantes anónimos solo leen artículos publicados, los editores solo modifican los suyos y solo un administrador borra.
  • Hook beforeChange: se ejecuta antes de guardar; asigna el autor y genera el slug a partir del título si no se indicó.
  • Hook afterChange: se ejecuta después de guardar y compara con previousDoc para actuar solo cuando el artículo pasa a publicado. Es el lugar para invalidar una caché o avisar a otro servicio.

Registra la colección en la configuración principal:

nano src/payload.config.ts

Importa Posts junto a las otras colecciones y añádelo al array collections. Deja el resto del archivo como lo generó el asistente:

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

// ...

export default buildConfig({
  // ...
  collections: [Users, Media, Posts],
  // ...
})

Regenera los tipos de TypeScript, que Payload deriva de la configuración:

npm run generate:types

Arranca de nuevo con npm run dev. En desarrollo, el adaptador de PostgreSQL sincroniza el esquema automáticamente y verás la colección Artículos en el panel. Asigna el rol admin a tu usuario como se indicó en el paso anterior.

Paso 6: Probar la API REST y GraphQL

Payload genera los endpoints de cada colección en /api/<slug>. Con el servidor de desarrollo en marcha, abre otra sesión SSH e inicia sesión para obtener un token:

TOKEN=$(curl -s -X POST http://localhost:3000/api/users/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@your_domain","password":"your_admin_password"}' | jq -r '.token')

Si jq no está instalado, instálalo con sudo apt install jq. Crea un artículo publicado:

curl -s -X POST http://localhost:3000/api/posts \
  -H "Authorization: JWT $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Hola desde la API","estado":"publicado"}' | jq '.doc.slug'
"hola-desde-la-api"

El slug lo ha generado el hook. Ahora consulta sin token, como haría un visitante; solo aparecen los artículos publicados:

curl -s "http://localhost:3000/api/posts?where[estado][equals]=publicado&sort=-createdAt&limit=10&depth=1" | jq '.docs[].titulo'
"Hola desde la API"

where filtra, sort ordena (el signo menos invierte el orden), limit pagina y depth indica cuántos niveles de relaciones se rellenan con el documento completo en lugar del ID.

La API GraphQL está en /api/graphql y el explorador interactivo en /api/graphql-playground durante el desarrollo. La consulta equivalente es:

query {
  Posts(where: { estado: { equals: publicado } }, sort: "-createdAt", limit: 10) {
    docs {
      titulo
      slug
    }
    totalDocs
  }
}

Paso 7: Leer contenido desde Next.js con la Local API

Como el frontend vive en el mismo proyecto, no necesita llamar a la API por HTTP: la Local API consulta la base de datos directamente y respeta los mismos tipos. Crea una página para cada artículo:

mkdir -p "src/app/(frontend)/blog/[slug]"
nano "src/app/(frontend)/blog/[slug]/page.tsx"
import { getPayload } from 'payload'
import config from '@payload-config'
import { notFound } from 'next/navigation'

export default async function ArticuloPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const payload = await getPayload({ config })

  const { docs } = await payload.find({
    collection: 'posts',
    where: {
      slug: { equals: slug },
      estado: { equals: 'publicado' },
    },
    limit: 1,
  })

  const post = docs[0]
  if (!post) notFound()

  return (
    <article>
      <h1>{post.titulo}</h1>
    </article>
  )
}

Abre http://localhost:3000/blog/hola-desde-la-api a través del túnel y verás el título del artículo. La Local API omite el control de acceso por defecto, por eso el filtro por estado se incluye de forma explícita en la consulta.

Paso 8: Preparar la base de datos para producción

La sincronización automática del esquema solo se usa en desarrollo. En producción, los cambios de esquema se aplican con migraciones versionadas. Genera la migración inicial:

npx payload migrate:create inicial

El comando crea un archivo en src/migrations/. Aplícalo:

npx payload migrate

Como en este servidor también has usado el modo de desarrollo, Payload avisa de que el esquema se modificó con sincronización automática y pide confirmación. En una base de datos de producción que nunca haya ejecutado npm run dev no aparece esa pregunta; por eso lo habitual es desarrollar en otra máquina y usar el servidor solo con migraciones.

Cada vez que cambies campos o colecciones, repite migrate:create en desarrollo, confirma el archivo en Git y ejecuta migrate en el servidor antes de reiniciar la aplicación.

Paso 9: Ejecutar Payload con systemd

Compila la aplicación para producción:

npm run build

Crea una unidad de systemd que la arranque y la reinicie si falla:

sudo nano /etc/systemd/system/payload.service
[Unit]
Description=Payload CMS
After=network.target postgresql.service

[Service]
Type=simple
User=your_user
WorkingDirectory=/var/www/payload/cms
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/npm run start
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Next.js lee el archivo .env del directorio de trabajo, así que no hace falta repetir las variables en la unidad. Activa el servicio:

sudo systemctl daemon-reload
sudo systemctl enable --now payload
sudo systemctl status payload
● payload.service - Payload CMS
     Loaded: loaded (/etc/systemd/system/payload.service; enabled; preset: enabled)
     Active: active (running) since ...

Si el estado no es active (running), consulta los logs con sudo journalctl -u payload -n 50.

Paso 10: Publicar con Nginx y HTTPS

Crea el bloque de servidor:

sudo nano /etc/nginx/sites-available/payload
server {
    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;
    }
}

Actívalo, abre el cortafuegos y obtén el certificado:

sudo ln -s /etc/nginx/sites-available/payload /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d cms.your_domain

Abre https://cms.your_domain/admin e inicia sesión. El puerto 3000 no está abierto en el cortafuegos, así que todo el tráfico pasa por Nginx.

Solución de problemas

npm run build termina con JavaScript heap out of memory. El servidor no tiene memoria suficiente para compilar. Añade 2 GB de swap o compila en otra máquina y copia el proyecto.

Error password authentication failed for user "payload". La contraseña de la cadena de conexión en .env no coincide con la de PostgreSQL. Cámbiala con ALTER USER payload WITH PASSWORD '...' o corrige .env y reinicia el servicio.

Error de tipos en user.rol al compilar. Los tipos están desactualizados; ejecuta npm run generate:types después de cualquier cambio en las colecciones.

En producción faltan tablas o columnas nuevas. No se ha aplicado la migración correspondiente. Ejecuta npx payload migrate en el directorio del proyecto y reinicia con sudo systemctl restart payload.

Conclusión

Tienes Payload 3 en producción con PostgreSQL, una colección con control de acceso por roles y hooks, acceso por REST, GraphQL y Local API, y el servicio gestionado por systemd tras Nginx con HTTPS. Como siguientes pasos, configura un adaptador de almacenamiento como @payloadcms/storage-s3 para no guardar los archivos subidos en el disco del servidor, programa copias de seguridad con pg_dump y activa versions: { drafts: true } en la colección si tu equipo necesita borradores y revisiones.