Directus es un headless CMS de código abierto que se coloca encima de una base de datos SQL y expone su contenido mediante una API REST y GraphQL, con un panel de administración para editores. En este tutorial desplegarás Directus 11 con PostgreSQL y Redis mediante Docker Compose en Ubuntu 24.04, lo publicarás tras Nginx con HTTPS y configurarás las funciones que se usan en producción: permisos por políticas, automatizaciones con Flows, una extensión de tipo hook y transformación de imágenes.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS y al menos 2 GB de RAM, por ejemplo un VPS de CubePath.
- Un usuario no root con privilegios
sudo. - Docker Engine y el plugin de Docker Compose instalados desde el repositorio oficial de Docker.
- Nginx y Certbot instalados (
sudo apt install nginx certbot python3-certbot-nginx). - Un dominio, por ejemplo
cms.your_domain, con un registro DNS A apuntando ayour_server_ip. - Node.js 20 o superior en tu equipo de desarrollo, solo para compilar la extensión del paso 6.
jqen el servidor para leer respuestas JSON (sudo apt install jq).
Sustituye your_domain, your_server_ip y el resto de valores de ejemplo por los tuyos a lo largo de la guía.
Paso 1: Preparar el directorio y los secretos
Directus guarda en disco los archivos subidos y las extensiones. Crea un directorio para el proyecto y los dos subdirectorios que se montarán en el contenedor:
sudo mkdir -p /opt/directus/uploads /opt/directus/extensions
cd /opt/directus
La imagen oficial ejecuta Directus con el usuario node (UID 1000), así que ese usuario debe poder escribir en los volúmenes:
sudo chown -R 1000:1000 /opt/directus/uploads /opt/directus/extensions
Genera dos valores aleatorios, uno para la contraseña de PostgreSQL y otro para SECRET, la clave con la que Directus firma los tokens de sesión:
openssl rand -base64 32
openssl rand -base64 32
Guárdalos en un archivo .env que leerá Docker Compose:
sudo nano /opt/directus/.env
DB_PASSWORD=pega_aqui_el_primer_valor
DIRECTUS_SECRET=pega_aqui_el_segundo_valor
ADMIN_EMAIL=admin@your_domain
ADMIN_PASSWORD=your_strong_password
PUBLIC_URL=https://cms.your_domain
Restringe los permisos del archivo, ya que contiene credenciales:
sudo chmod 600 /opt/directus/.env
Paso 2: Definir los servicios con Docker Compose
El despliegue tiene tres servicios: PostgreSQL como base de datos, Redis como caché y Directus. Directus solo escucha en 127.0.0.1, porque el tráfico público entrará por Nginx.
sudo nano /opt/directus/docker-compose.yml
services:
database:
image: postgres:16
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: directus
POSTGRES_USER: directus
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD", "pg_isready", "-U", "directus", "-d", "directus"]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7
restart: unless-stopped
directus:
image: directus/directus:11
restart: unless-stopped
ports:
- "127.0.0.1:8055:8055"
volumes:
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
depends_on:
database:
condition: service_healthy
cache:
condition: service_started
environment:
SECRET: ${DIRECTUS_SECRET}
PUBLIC_URL: ${PUBLIC_URL}
DB_CLIENT: pg
DB_HOST: database
DB_PORT: 5432
DB_DATABASE: directus
DB_USER: directus
DB_PASSWORD: ${DB_PASSWORD}
CACHE_ENABLED: "true"
CACHE_AUTO_PURGE: "true"
CACHE_STORE: redis
REDIS: redis://cache:6379
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION: 4000
volumes:
postgres_data:
Algunas decisiones de esta configuración:
- La etiqueta
directus/directus:11fija la versión mayor. Conlatestuna actualización a una versión mayor nueva podría llegar sin que la esperes y aplicar migraciones de base de datos. CACHE_AUTO_PURGEvacía la caché de Redis cada vez que se modifica contenido, de modo que la API nunca devuelve datos antiguos.ADMIN_EMAILyADMIN_PASSWORDsolo se usan en el primer arranque, para crear el administrador inicial.ASSETS_TRANSFORM_IMAGE_MAX_DIMENSIONlimita el tamaño máximo de las imágenes transformadas y evita que alguien agote la memoria pidiendo miniaturas enormes.
Arranca los servicios:
sudo docker compose up -d
En el primer arranque Directus crea sus tablas en PostgreSQL. Sigue los logs hasta ver que el servidor escucha:
sudo docker compose logs -f directus
directus-1 | [..] INFO: Initializing bootstrap...
directus-1 | [..] INFO: Database empty, creating new schema
directus-1 | [..] INFO: Adding first admin user...
directus-1 | [..] INFO: Server started at http://0.0.0.0:8055
Pulsa Ctrl+C para salir de los logs y comprueba que la API responde:
curl http://127.0.0.1:8055/server/ping
pong
Paso 3: Publicar Directus con Nginx y HTTPS
Crea un bloque de servidor de Nginx que reenvíe las peticiones a Directus:
sudo nano /etc/nginx/sites-available/directus
server {
listen 80;
server_name cms.your_domain;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:8055;
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;
}
}
client_max_body_size permite subir archivos de hasta 100 MB; el valor por defecto de Nginx (1 MB) haría fallar la mayoría de subidas de imágenes. Activa el sitio, comprueba la sintaxis y recarga Nginx:
sudo ln -s /etc/nginx/sites-available/directus /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Abre HTTP y HTTPS en el cortafuegos si usas UFW y solicita el certificado:
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d cms.your_domain
Certbot modifica el bloque de servidor para servir HTTPS y redirigir HTTP. Abre https://cms.your_domain en el navegador e inicia sesión con ADMIN_EMAIL y ADMIN_PASSWORD. Cambia la contraseña del administrador desde su perfil y elimina ADMIN_PASSWORD del archivo .env, ya que no se volverá a usar.
Paso 4: Crear una colección y controlar el acceso con políticas
A partir de Directus 11 los permisos no se asignan directamente a un rol: se definen en políticas de acceso y cada rol o usuario recibe una o varias políticas. Así puedes reutilizar la misma política en varios roles.
Primero crea una colección de ejemplo en Settings > Data Model > Create Collection, con el nombre articulos. En la pantalla de creación marca los campos opcionales Status, User Created y Date Created. Después añade dos campos de tipo Input: titulo y slug, y uno de tipo WYSIWYG llamado contenido. El campo status que crea Directus tiene los valores published, draft y archived.
Ahora crea la política para los editores en Settings > Access Policies > Create Policy:
- Nombre:
Editores de artículos. Activa App Access para que puedan entrar al panel. - En la sección de permisos añade la colección
articulos. - Create: acceso completo.
- Read: acceso completo, para que vean el trabajo de todo el equipo.
- Update: acceso personalizado con la regla de ítems
User Createdigual a$CURRENT_USER. En la pestaña de campos, deja marcados solotitulo,slug,contenidoystatus. - Delete: sin acceso.
Por último crea el rol en Settings > User Roles > Create Role, llámalo Editor, asígnale la política Editores de artículos y crea un usuario de prueba con ese rol en User Directory.
Comprueba desde la API que las reglas funcionan. Obtén un token del usuario editor:
EDITOR_TOKEN=$(curl -s -X POST https://cms.your_domain/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"editor@your_domain","password":"password_del_editor"}' \
| jq -r '.data.access_token')
Crea un artículo y comprueba que se guarda con el editor como autor:
curl -s -X POST https://cms.your_domain/items/articulos \
-H "Authorization: Bearer $EDITOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"titulo":"Primer artículo","slug":"primer-articulo","status":"draft"}' | jq '.data.id'
1
Si intentas borrarlo, Directus responde con un error de permisos:
curl -s -X DELETE https://cms.your_domain/items/articulos/1 \
-H "Authorization: Bearer $EDITOR_TOKEN" | jq '.errors[0].extensions.code'
"FORBIDDEN"
Para publicar solo el contenido publicado sin autenticación, edita la política Public y concede Read sobre articulos con la regla Status igual a published.
Paso 5: Automatizar tareas con Flows
Los Flows son la herramienta de automatización integrada en Directus y sustituyen a los antiguos webhooks, que se eliminaron en la versión 11. Un Flow se compone de un disparador y una cadena de operaciones. Los disparadores disponibles son:
| Disparador | Cuándo se ejecuta |
|---|---|
| Event Hook | Al crear, actualizar o borrar ítems. En modo Filter bloquea la operación hasta terminar; en modo Action se ejecuta después, sin bloquear |
| Webhook | Al recibir una petición HTTP en /flows/trigger/<id> |
| Schedule (CRON) | Según una expresión cron |
| Another Flow | Cuando otro Flow lo invoca |
| Manual | Desde un botón en el panel de una colección |
Como ejemplo, crea un Flow que avise a un sistema externo cada vez que se publica un artículo. En Settings > Flows > Create Flow:
- Nombre:
Notificar publicación. - Disparador: Event Hook, tipo Action (Non-Blocking), alcance
items.update, colecciónarticulos. - Añade una operación Condition con esta regla, para continuar solo cuando el cambio pone el estado en
published:
{
"$trigger": {
"payload": {
"status": {
"_eq": "published"
}
}
}
}
- En la salida de éxito de la condición añade una operación Run Script que prepare el cuerpo de la notificación:
module.exports = async function (data) {
return {
ids: data.$trigger.keys,
publicado_en: new Date().toISOString(),
};
};
- A continuación añade una operación Webhook / Request URL con método
POST, la URL de tu sistema externo y como cuerpo{{$last}}, que contiene el resultado del script anterior.
Guarda el Flow, cambia el estado de un artículo a published desde el panel y abre el Flow: el icono de registros de la barra lateral muestra cada ejecución, con los datos que recibió y devolvió cada operación. Si una ejecución falla, ese registro es el primer sitio que debes revisar.
Notalos scripts de Run Script se ejecutan en un entorno aislado sin acceso al sistema de archivos ni a módulos de Node.js. Para lógica que necesita dependencias, usa una extensión como la del paso siguiente.
Paso 6: Crear una extensión de tipo hook
Las extensiones permiten añadir código propio a la API o al panel. Una extensión de tipo hook se ejecuta en eventos del servidor, igual que un Flow, pero con todo el ecosistema de npm disponible. En este ejemplo validarás el título y generarás el slug automáticamente al crear un artículo.
En tu equipo de desarrollo, genera el esqueleto de la extensión:
npx create-directus-extension@latest
Responde al asistente con el tipo hook, el nombre directus-extension-articulos y el lenguaje javascript. Después entra en el directorio y abre el código:
cd directus-extension-articulos
nano src/index.js
Sustituye el contenido por lo siguiente:
import { defineHook } from '@directus/extensions-sdk';
import { createError } from '@directus/errors';
const TituloInvalido = createError(
'INVALID_PAYLOAD',
'El título debe tener al menos 5 caracteres',
400,
);
function slugify(texto) {
return texto
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export default defineHook(({ filter }, { logger }) => {
filter('articulos.items.create', (payload) => {
if (!payload.titulo || payload.titulo.trim().length < 5) {
throw new TituloInvalido();
}
if (!payload.slug) {
payload.slug = slugify(payload.titulo);
}
logger.info(`Artículo nuevo con slug ${payload.slug}`);
return payload;
});
});
filter se ejecuta antes de guardar y puede modificar o rechazar los datos; action se ejecuta después y sirve para efectos secundarios. Compila la extensión:
npm run build
Directus 11 carga cada extensión desde su propio directorio dentro de extensions/, con su package.json y la carpeta dist. Copia ambos al servidor:
ssh your_user@your_server_ip 'mkdir -p ~/directus-extension-articulos'
scp -r package.json dist your_user@your_server_ip:~/directus-extension-articulos/
En el servidor, muévela al directorio de extensiones, ajusta el propietario y reinicia Directus:
sudo mv ~/directus-extension-articulos /opt/directus/extensions/
sudo chown -R 1000:1000 /opt/directus/extensions
cd /opt/directus
sudo docker compose restart directus
Comprueba que la extensión se ha cargado:
sudo docker compose logs directus | grep -i extension
directus-1 | [..] INFO: Loaded extensions: directus-extension-articulos
Crea un artículo con un título corto para verificar la validación:
curl -s -X POST https://cms.your_domain/items/articulos \
-H "Authorization: Bearer $EDITOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"titulo":"Hola"}' | jq '.errors[0].message'
"El título debe tener al menos 5 caracteres"
Con un título válido y sin slug, el artículo se guarda con el slug generado, por ejemplo configuracion-de-produccion para el título "Configuración de producción".
Paso 7: Transformar imágenes al vuelo
Directus sirve los archivos en /assets/<id> y puede redimensionarlos y convertirlos de formato en la propia petición, guardando el resultado para las siguientes. Los parámetros principales son:
| Parámetro | Valores |
|---|---|
width, height | Tamaño en píxeles |
fit | cover, contain, inside, outside |
quality | 1 a 100 |
format | auto, jpg, png, webp, tiff, avif |
withoutEnlargement | true para no ampliar imágenes pequeñas |
Sube una imagen desde File Library y copia su identificador. Con un archivo accesible para el público, esta petición devuelve una miniatura de 400x300 en WebP:
curl -s -o miniatura.webp \
"https://cms.your_domain/assets/ID_DEL_ARCHIVO?width=400&height=300&fit=cover&format=webp&quality=80"
file miniatura.webp
miniatura.webp: RIFF (little-endian) data, Web/P image
Permitir cualquier combinación de parámetros deja que un cliente genere miles de variantes y llene el disco. En producción, define tamaños con nombre en Settings > Settings > Files & Storage > Storage Asset Presets (por ejemplo miniatura con 400x300, cover, WebP) y cambia Storage Asset Transform a Presets Only. Los clientes piden entonces /assets/ID_DEL_ARCHIVO?key=miniatura.
Solución de problemas
Directus se reinicia en bucle con errores de base de datos. Suele ocurrir cuando se cambia DB_PASSWORD después del primer arranque: PostgreSQL ya creó el usuario con la contraseña anterior y la variable solo se aplica al inicializar el volumen. Restablece la contraseña dentro del contenedor:
sudo docker compose exec database psql -U directus -d directus -c "ALTER USER directus WITH PASSWORD 'la_nueva_contraseña';"
Las subidas fallan con EACCES. Los directorios montados no pertenecen al UID 1000. Repite el chown del paso 1.
Las subidas grandes devuelven 413 Request Entity Too Large. Aumenta client_max_body_size en el bloque de Nginx y recárgalo.
La extensión no aparece en los logs. Comprueba que la ruta es /opt/directus/extensions/directus-extension-articulos/package.json y que existe dist/index.js. Una carpeta con el tipo como nombre (extensions/hooks/...) corresponde al formato antiguo y no se carga.
Un Flow no se dispara. Verifica que está activo, que el alcance (items.create, items.update) y la colección coinciden y revisa sus registros de ejecución.
Conclusión
Tienes Directus 11 funcionando con PostgreSQL y Redis tras Nginx con HTTPS, permisos basados en políticas, un Flow que reacciona a la publicación de contenido, una extensión propia que valida datos y transformaciones de imagen limitadas a presets. Como siguientes pasos, programa copias de seguridad diarias de la base de datos con pg_dump y del directorio uploads, mueve los archivos a un almacenamiento compatible con S3 mediante las variables STORAGE_* si vas a escalar a varias instancias, y consulta el SDK @directus/sdk para consumir la API desde tu frontend.
