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 a your_server_ip.
  • Node.js 20 o superior en tu equipo de desarrollo, solo para compilar la extensión del paso 6.
  • jq en 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:11 fija la versión mayor. Con latest una actualización a una versión mayor nueva podría llegar sin que la esperes y aplicar migraciones de base de datos.
  • CACHE_AUTO_PURGE vacía la caché de Redis cada vez que se modifica contenido, de modo que la API nunca devuelve datos antiguos.
  • ADMIN_EMAIL y ADMIN_PASSWORD solo se usan en el primer arranque, para crear el administrador inicial.
  • ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION limita 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:

  1. Nombre: Editores de artículos. Activa App Access para que puedan entrar al panel.
  2. En la sección de permisos añade la colección articulos.
  3. Create: acceso completo.
  4. Read: acceso completo, para que vean el trabajo de todo el equipo.
  5. Update: acceso personalizado con la regla de ítems User Created igual a $CURRENT_USER. En la pestaña de campos, deja marcados solo titulo, slug, contenido y status.
  6. 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:

DisparadorCuándo se ejecuta
Event HookAl crear, actualizar o borrar ítems. En modo Filter bloquea la operación hasta terminar; en modo Action se ejecuta después, sin bloquear
WebhookAl recibir una petición HTTP en /flows/trigger/<id>
Schedule (CRON)Según una expresión cron
Another FlowCuando otro Flow lo invoca
ManualDesde 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:

  1. Nombre: Notificar publicación.
  2. Disparador: Event Hook, tipo Action (Non-Blocking), alcance items.update, colección articulos.
  3. 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"
      }
    }
  }
}
  1. 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(),
  };
};
  1. 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.

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ámetroValores
width, heightTamaño en píxeles
fitcover, contain, inside, outside
quality1 a 100
formatauto, jpg, png, webp, tiff, avif
withoutEnlargementtrue 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.