--- title: "Cómo instalar Payload CMS con PostgreSQL en Ubuntu 24.04" description: "Instala Payload CMS 3 con Node.js 22 y PostgreSQL en Ubuntu 24.04, define colecciones con control de acceso y hooks, y publícalo con systemd, Nginx y HTTPS." category: "Aplicaciones Web Extendidas" language: es source: https://cubepath.com/es/docs/aplicaciones-web-extendidas/instalacion-y-configuracion-de-payload-cms updated: 2026-09-25 --- # Cómo instalar Payload CMS con PostgreSQL en Ubuntu 24.04 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: ```bash 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: ```bash sudo apt install -y nodejs ``` Comprueba las versiones: ```bash node --version npm --version ``` ```output 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: ```bash 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`): ```bash 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: ```bash psql "postgresql://payload:your_strong_password@127.0.0.1:5432/payload" -c "SELECT current_user;" ``` ```output 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: ```bash 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: ```bash 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:your_strong_password@127.0.0.1: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: ```bash cd /var/www/payload/cms ls src src/collections ``` ```output 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: ```bash npm run dev ``` ```output ▲ 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: ```bash 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: ```bash nano src/collections/Users.ts ``` Sustituye su contenido por lo siguiente: ```typescript 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. > Importante: el usuario que creaste en el paso 3 ya existía antes de este campo. Cuando arranques de nuevo, edita ese usuario en el panel y asígnale el rol `admin`, o te quedarás sin permisos para gestionar usuarios. ## Paso 5: Crear la colección de artículos Crea el archivo de la colección: ```bash nano src/collections/Posts.ts ``` ```typescript 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: ```bash 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: ```typescript 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: ```bash 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/`. Con el servidor de desarrollo en marcha, abre otra sesión SSH e inicia sesión para obtener un token: ```bash 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: ```bash 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' ``` ```output "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: ```bash curl -s "http://localhost:3000/api/posts?where[estado][equals]=publicado&sort=-createdAt&limit=10&depth=1" | jq '.docs[].titulo' ``` ```output "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: ```graphql 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: ```bash mkdir -p "src/app/(frontend)/blog/[slug]" nano "src/app/(frontend)/blog/[slug]/page.tsx" ``` ```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 (

{post.titulo}

) } ``` 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: ```bash npx payload migrate:create inicial ``` El comando crea un archivo en `src/migrations/`. Aplícalo: ```bash 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: ```bash npm run build ``` Crea una unidad de systemd que la arranque y la reinicie si falla: ```bash sudo nano /etc/systemd/system/payload.service ``` ```ini [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: ```bash sudo systemctl daemon-reload sudo systemctl enable --now payload sudo systemctl status payload ``` ```output ● 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: ```bash sudo nano /etc/nginx/sites-available/payload ``` ```nginx 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: ```bash 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.