Ir al contenido
Infraestructura & Tecnología

Trigger.dev Background Jobs de forma segura

Runbook DevOps para Trigger.dev v3: setup self-hosted, definición de tareas, idempotencia, concurrency, secrets e integración con Claude Code.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 18 min de lectura

En cuanto una aplicación va más allá de simples operaciones CRUD, surgen tareas que no deben ejecutarse de forma síncrona en el ciclo request-response: envío de correos electrónicos, procesamiento de webhooks, trabajos de importación/exportación, tareas de IA, generación de PDF, migración de datos y tareas periódicas.

Estas tareas no pertenecen a las Next.js Server Actions (bloquean el servidor web), no a las Supabase Edge Functions (límite de timeout, no admiten procesos de larga duración) y tampoco a los cron jobs en el servidor (sin lógica de reintentos, sin monitorización).

Pertenecen a una capa de jobs dedicada. En nuestro stack, Trigger.dev asume este rol.

Este runbook describe cómo operar Trigger.dev de forma segura en el stack. Cada paso contiene una implementación concreta con la API actual de Trigger.dev v3, una condición verificable y un escenario de fallo.

Nota sobre la arquitectura: Trigger.dev consta de dos partes: la Platform (Webapp, Dashboard, Queue Management) y el Worker (ejecuta las tareas). Nosotros operamos ambos self-hosted en nuestra propia infraestructura. Este runbook describe exclusivamente el setup self-hosted con Trigger.dev v3.

De un vistazo - Artículo 4 de 6 de la serie DevOps Runbook

  • Trigger.dev como stack Docker separado con su propio PostgreSQL
  • Cada task necesita maxDuration, concurrencyLimit y configuración de retry
  • Claves de idempotencia en llamadas API externas
  • Acceso a base de datos limitado a campos necesarios
  • Logger de Trigger.dev en lugar de console.log

Índice de la serie

Esta guía forma parte de nuestra serie de runbooks DevOps para app stacks self-hosted.

  1. Supabase Self-Hosting Runbook
  2. Next.js sobre Supabase de forma segura
  3. Supabase Edge Functions de forma segura
  4. Trigger.dev Background Jobs de forma segura - este artículo
  5. Claude Code como control de seguridad en el workflow DevOps
  6. Security Baseline para todo el stack

El artículo 1 describe la plataforma. El artículo 2 describe la capa de aplicación. El artículo 3 describe las integraciones. Este artículo describe el procesamiento asíncrono de jobs.

Visión general de la arquitectura

Browser
   |
Next.js (capa de aplicación)
   |
   +-- tasks.trigger("send-email", payload)    <- iniciar job
   |
Trigger.dev Platform
   |
   +-- Queue Management
   +-- Retry Logic
   +-- Dashboard / Monitoring
   |
Trigger.dev Worker
   |
   +-- send-email.ts           -> SMTP API
   +-- generate-report.ts      -> PDF + Supabase Storage
   +-- process-webhook.ts      -> Supabase DB (service_role)
   +-- sync-crm.ts             -> externe CRM API
   |
   +-- Supabase (via service_role oder direkte DB Connection)
       |
       PostgreSQL

Reglas fundamentales:

User Request       -> Next.js Server Action / Route Handler
Kurzer Event       -> Supabase Edge Function
Langläufer         -> Trigger.dev Task
Periodisch         -> Trigger.dev Scheduled Task

Punto de seguridad crítico

Las tareas de Trigger.dev tienen típicamente acceso completo a la base de datos sin RLS. Se conectan mediante la clave service_role (a través del Supabase Client) o mediante una conexión directa a PostgreSQL (a través de DATABASE_URL). En ambos casos, las políticas de Row Level Security no se aplican. Esta es la diferencia más importante respecto a las solicitudes que pasan por PostgREST con la clave anon.

Quien conozca los fundamentos de la gestión de secrets entenderá por qué esta separación es esencial.

Guía de configuración de tasks

Tipo de taskmaxDurationconcurrencyLimitEstrategia de Retry
Envío de email30s5-103 intentos, exponential backoff
Generación de PDF120s2-32 intentos, intervalo fijo
Inferencia AI (LLM)300s1-32 intentos, exponential backoff
Migración de datos600s11 intento, manejo manual
Procesamiento de webhooks30s103 intentos, exponential backoff

Parte A - Decisiones de arquitectura

A1 - Operar Trigger.dev v3 self-hosted como servicio independiente

Implementación

Trigger.dev se ejecuta separado de Next.js y Supabase como su propio stack Docker. La arquitectura self-hosted v3 consta de tres componentes:

Next.js App (vuestro servidor Hetzner / OVH / Scaleway)
   |
   +-- Trigger.dev Platform (Webapp + Queue + Dashboard)
           |
           +-- Trigger.dev Worker (führt eure Tasks aus)
           |
           +-- Trigger.dev PostgreSQL (eigene DB-Instanz)
# docker-compose.trigger.yml
services:
  trigger-platform:
    image: ghcr.io/triggerdotdev/trigger.dev:v3    # Version pinnen
    restart: unless-stopped
    ports:
      - "127.0.0.1:3040:3040"        # NUR localhost, Reverse Proxy davor
    environment:
      DATABASE_URL: postgresql://postgres:${TRIGGER_DB_PASSWORD}@trigger-db:5433/trigger
      DIRECT_URL: postgresql://postgres:${TRIGGER_DB_PASSWORD}@trigger-db:5433/trigger
      LOGIN_ORIGIN: https://trigger.example.com
      APP_ORIGIN: https://trigger.example.com
      MAGIC_LINK_SECRET: ${MAGIC_LINK_SECRET}
      SESSION_SECRET: ${SESSION_SECRET}
      ENCRYPTION_KEY: ${ENCRYPTION_KEY}
      RUNTIME_PLATFORM: docker-compose
      # Worker-Konfiguration
      DOCKER_SOCKET_LOCATION: /var/run/docker.sock
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock   # Worker-Management
    depends_on:
      trigger-db:
        condition: service_healthy
    networks:
      - trigger-internal

  trigger-db:
    image: postgres:15
    restart: unless-stopped
    ports:
      - "127.0.0.1:5433:5432"        # Port 5433, NICHT 5432 (Supabase nutzt 5432)
    environment:
      POSTGRES_PASSWORD: ${TRIGGER_DB_PASSWORD}
      POSTGRES_DB: trigger
    volumes:
      - trigger-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - trigger-internal

volumes:
  trigger-db-data:

networks:
  trigger-internal:
    driver: bridge

Importante: Trigger.dev necesita su propia instancia de PostgreSQL en el puerto 5433. No se debe compartir la base de datos de Supabase (puerto 5432). Trigger.dev almacena el estado de la cola, el historial de ejecuciones, el estado de los workers y los metadatos en su base de datos. Son datos diferentes a los datos de la aplicación.

Configurar el SDK en el proyecto Next.js:

# Trigger.dev SDK installieren
npm install @trigger.dev/sdk@3

# .env (Next.js)
TRIGGER_SECRET_KEY=tr_dev_...              # aus dem self-hosted Dashboard
TRIGGER_API_URL=http://localhost:3040      # lokale Platform URL

Despliegue del Worker:

En el setup self-hosted v3, las tareas se despliegan a través de la CLI, que se comunica con la Platform local:

# Tasks deployen (zeigt auf eure self-hosted Platform)
npx trigger.dev@latest deploy --self-hosted

# Lokale Entwicklung
npx trigger.dev@latest dev

Nota: El Worker v3 de Docker utiliza Docker-in-Docker (Socket Mounting). Esto significa que la Platform de Trigger.dev necesita acceso al Docker Socket (/var/run/docker.sock). Esto tiene implicaciones de seguridad: un contenedor de Trigger.dev comprometido podría iniciar contenedores Docker arbitrarios. Por eso, lo ideal es que Trigger.dev se ejecute en un servidor propio o en una red Docker aislada.

Condición verificable

# Container laufen?
docker compose -f docker-compose.trigger.yml ps
# Erwartung: trigger-platform und trigger-db running

# Platform erreichbar (intern)?
curl -s -o /dev/null -w "%{http_code}" http://localhost:3040
# Erwartung: 200 oder 302 (Redirect zu Login)

# Platform von außen NICHT direkt erreichbar?
curl -s -o /dev/null -w "%{http_code}" https://trigger.example.com:3040
# Erwartung: Connection refused (nur über Reverse Proxy)

# Trigger.dev DB auf eigenem Port?
ss -tlnp | grep 5433
# Erwartung: Listening auf 127.0.0.1:5433

# Supabase DB auf anderem Port?
ss -tlnp | grep 5432
# Erwartung: Listening auf 10.0.1.10:5432 (internes Interface)

# Docker Socket gemountet?
docker compose -f docker-compose.trigger.yml exec trigger-platform ls -la /var/run/docker.sock
# Erwartung: Socket vorhanden

Escenario de fallo

Si Trigger.dev se ejecuta en el mismo proceso que Next.js, las tareas de larga duración bloquean el servidor web. Una tarea de IA que necesita 5 minutos ocupa un hilo de worker de Next.js. Con pocos hilos de worker (por defecto: 1 por CPU), toda la aplicación deja de responder para los usuarios. Si Trigger.dev comparte la base de datos de Supabase, las consultas de la cola de jobs compiten con las solicitudes de los usuarios por las conexiones de la base de datos. Si el Docker Socket se monta sin aislamiento de red, una tarea comprometida puede iniciar contenedores que accedan a la red del host.

A2 - Controlar el acceso a la base de datos desde las tareas

Implementación

Las tareas necesitan acceso a los datos de la aplicación en Supabase. Hay dos formas:

Opción 1: Supabase Client con service_role (recomendado)

// trigger/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

export function createTaskClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  )
}

Ventaja: Pasa por PostgREST, utiliza la capa API de Supabase. Desventaja: Omite RLS (service_role).

Opción 2: Conexión directa a la BD (para consultas complejas)

// trigger/lib/db.ts
import postgres from 'postgres'

const sql = postgres(process.env.DATABASE_URL!, {
  max: 5,                    // Connection Pool limitieren
  idle_timeout: 20,
  connect_timeout: 10,
})

export { sql }

Desventaja: Omite tanto RLS como PostgREST. Acceso completo a la base de datos.

Regla: Independientemente de la opción, la tarea tiene más permisos que una solicitud normal de usuario. Por eso, cada tarea debe definir claramente su propio alcance.

// RICHTIG: Task greift nur auf das zu, was er braucht
const { data } = await supabase
  .from('invoices')
  .select('id, amount, user_id')
  .eq('id', payload.invoiceId)
  .single()

// FALSCH: Task liest alle Daten einer Tabelle
const { data } = await supabase
  .from('invoices')
  .select('*')

Quien conozca los patrones RLS del Runbook de Supabase Self-Hosting entenderá por qué la diferencia de alcance es crítica.

Condición verificable

# Welche Tasks nutzen service_role oder DATABASE_URL?
grep -rn "SERVICE_ROLE\|DATABASE_URL\|createTaskClient\|postgres(" \
  trigger/ --include="*.ts"

# Für jeden Treffer manuell prüfen:
# - Greift der Task nur auf die Daten zu, die er braucht?
# - Ist der Input validiert bevor er in die Query fließt?
# - Gibt es einen Grund für direkten DB-Zugriff statt Supabase Client?

Escenario de fallo

Una tarea con acceso directo a la base de datos e input no validado puede conducir a SQL injection. Una tarea que ejecuta SELECT * sobre tablas grandes puede afectar el rendimiento de la base de datos para todos los usuarios. Un pool de conexiones sin control (sin límite max) puede ocupar todas las conexiones disponibles de PostgreSQL y paralizar toda la aplicación.

Parte B - Verificaciones de implementación

Estas reglas se aplican a cada tarea y deben comprobarse en cada despliegue.

B1 - Definir tareas correctamente (API v3)

Implementación

Trigger.dev v3 utiliza la función task() de @trigger.dev/sdk/v3. Cada tarea se exporta y tiene un ID único.

// trigger/tasks/send-welcome-email.ts
import { task } from '@trigger.dev/sdk/v3'
import { z } from 'zod'
import { createTaskClient } from '../lib/supabase'

// 1. Payload Schema definieren
const payloadSchema = z.object({
  userId: z.string().uuid(),
  email: z.string().email(),
})

export const sendWelcomeEmail = task({
  id: 'send-welcome-email',

  // 2. Retry-Strategie
  retry: {
    maxAttempts: 3,
    factor: 2,
    minTimeoutInMs: 1_000,
    maxTimeoutInMs: 30_000,
  },

  // 3. Concurrency begrenzen
  queue: {
    concurrencyLimit: 5,
  },

  // 4. Maximum Laufzeit
  maxDuration: 60,   // 60 Sekunden

  // 5. Run-Funktion
  run: async (payload) => {
    // Input validieren
    const parsed = payloadSchema.safeParse(payload)
    if (!parsed.success) {
      throw new Error(`Invalid payload: ${parsed.error.message}`)
    }

    const { userId, email } = parsed.data
    const supabase = createTaskClient()

    // User-Daten holen (nur was nötig ist)
    const { data: user } = await supabase
      .from('profiles')
      .select('full_name, locale')
      .eq('id', userId)
      .single()

    if (!user) {
      throw new Error(`User not found: ${userId}`)
    }

    // E-Mail senden
    const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        personalizations: [{ to: [{ email }] }],
        from: { email: 'hello@example.com' },
        subject: `Willkommen, ${user.full_name}!`,
        content: [{ type: 'text/plain', value: `...` }],
      }),
    })

    if (!response.ok) {
      // Fehler werfen, damit Retry greift
      throw new Error(`SendGrid error: ${response.status}`)
    }

    // Status in DB aktualisieren
    await supabase
      .from('profiles')
      .update({ welcome_email_sent: true })
      .eq('id', userId)

    return { success: true, userId }
  },
})

Disparar la tarea desde Next.js

// app/actions/auth.ts
'use server'

import { tasks } from '@trigger.dev/sdk/v3'
import type { sendWelcomeEmail } from '@/trigger/tasks/send-welcome-email'

export async function onUserSignup(userId: string, email: string) {
  // Task triggern (kehrt sofort zurück)
  const handle = await tasks.trigger<typeof sendWelcomeEmail>(
    'send-welcome-email',
    { userId, email }
  )

  // handle.id enthält die Run-ID für Tracking
  return { triggered: true, runId: handle.id }
}

Condición verificable

# Alle Tasks müssen exportiert sein
for file in trigger/tasks/*.ts; do
  if ! grep -q "export const" "$file"; then
    echo "WARNUNG: $file hat keinen exportierten Task"
  fi
done

# Alle Tasks müssen eine ID haben
grep -rn "id:" trigger/tasks/ --include="*.ts" | grep "task({" -A1

# Tasks dürfen NICHT aus Client-Code getriggert werden
grep -rn "tasks.trigger\|\.trigger(" app/ --include="*.tsx" | grep -v "use server"
# Erwartung: keine Treffer (nur in Server-Kontext)

Escenario de fallo

Una tarea sin export no es reconocida por Trigger.dev y desaparece durante el despliegue sin mensaje de error. Una tarea sin configuración de retry utiliza el valor por defecto (sin reintentos), de modo que un error temporal de la API (por ejemplo, un timeout de SendGrid) provoca la pérdida permanente del correo electrónico.

B2 - Garantizar la idempotencia

Implementación

Trigger.dev tiene un sistema de idempotencia integrado a través de idempotencyKey. Es preferible al chequeo findUnique implementado manualmente.

Al disparar (evita la ejecución duplicada):

// Wenn derselbe User zweimal schnell "Signup" klickt,
// wird der Task nur einmal ausgeführt
await tasks.trigger<typeof sendWelcomeEmail>(
  'send-welcome-email',
  { userId, email },
  {
    idempotencyKey: `welcome-email-${userId}`,
    idempotencyKeyTTL: '24h',     // Key gilt 24 Stunden
  }
)

Dentro de la tarea (evita efectos secundarios duplicados en reintentos):

import { task, idempotencyKeys } from '@trigger.dev/sdk/v3'

export const processPayment = task({
  id: 'process-payment',
  retry: { maxAttempts: 3 },

  run: async (payload: { orderId: string; amount: number }) => {
    // Idempotency Key für den Stripe-Call
    // Bei Retry wird der Stripe-Call NICHT wiederholt
    const stripeKey = await idempotencyKeys.create(
      `stripe-charge-${payload.orderId}`
    )

    const charge = await stripe.charges.create(
      {
        amount: payload.amount,
        currency: 'eur',
        source: 'tok_...',
      },
      { idempotencyKey: stripeKey }
    )

    // DB-Update ist idempotent (upsert statt insert)
    await supabase
      .from('payments')
      .upsert({
        order_id: payload.orderId,
        stripe_charge_id: charge.id,
        status: 'completed',
      }, { onConflict: 'order_id' })

    return { chargeId: charge.id }
  },
})

Condición verificable

# Tasks mit externen API-Calls sollten Idempotency Keys haben
for file in trigger/tasks/*.ts; do
  if grep -qE "fetch\(|stripe\.|sendgrid\.|resend\." "$file"; then
    if ! grep -qE "idempotencyKey|idempotencyKeys" "$file"; then
      echo "WARNUNG: $file hat externe API-Calls ohne Idempotency Key"
    fi
  fi
done

Escenario de fallo

Sin protección de idempotencia al disparar, un webhook duplicado (Stripe reintenta ante un timeout) puede activar la misma tarea dos veces. Sin idempotencia dentro de la tarea, un reintento tras un error parcial puede provocar pagos duplicados o correos electrónicos duplicados.

B3 - Configurar correctamente timeouts y concurrency

Implementación

Trigger.dev v3 incluye maxDuration (límite de tiempo de ejecución por tarea) y concurrencyLimit (ejecuciones paralelas). Ambos deben configurarse conscientemente para cada tarea.

import { task, queue } from '@trigger.dev/sdk/v3'

// Shared Queue für E-Mail-Tasks (verhindert SMTP Rate Limiting)
const emailQueue = queue({
  name: 'email-processing',
  concurrencyLimit: 5,       // max. 5 parallele E-Mail-Tasks
})

export const sendEmail = task({
  id: 'send-email',
  queue: emailQueue,
  maxDuration: 30,            // 30 Sekunden max

  run: async (payload) => {
    // ...
  },
})

// AI-Tasks brauchen mehr Zeit, aber weniger Parallelität
export const analyzeDocument = task({
  id: 'analyze-document',
  queue: { concurrencyLimit: 2 },   // max. 2 parallele AI-Calls
  maxDuration: 300,                  // 5 Minuten max

  run: async (payload) => {
    // ...
  },
})

// Kritische Tasks die sequentiell laufen müssen
export const processInvoice = task({
  id: 'process-invoice',
  queue: { concurrencyLimit: 1 },    // strikt sequentiell
  maxDuration: 60,

  run: async (payload) => {
    // ...
  },
})

Valores de referencia:

E-Mail senden:        maxDuration 30s,    concurrency 5-10
PDF generieren:       maxDuration 120s,   concurrency 2-3
AI-Inference (LLM):   maxDuration 300s,   concurrency 1-3
Datenbank-Migration:  maxDuration 600s,   concurrency 1
Webhook verarbeiten:  maxDuration 30s,    concurrency 10

Condición verificable

# Alle Tasks müssen maxDuration haben
for file in trigger/tasks/*.ts; do
  if ! grep -q "maxDuration" "$file"; then
    echo "WARNUNG: $file hat kein maxDuration"
  fi
done

# Alle Tasks müssen concurrencyLimit haben (oder eine shared Queue nutzen)
for file in trigger/tasks/*.ts; do
  if ! grep -qE "concurrencyLimit|queue:" "$file"; then
    echo "WARNUNG: $file hat kein Concurrency Limit"
  fi
done

Escenario de fallo

Sin maxDuration, una tarea puede ejecutarse indefinidamente si una llamada API queda colgada, bloqueando un slot de worker de forma permanente. Sin concurrencyLimit, 100 tareas de envío de correo disparadas simultáneamente pueden sobrecargar el proveedor SMTP y provocar Rate Limiting. Sin Concurrency Limit en tareas intensivas de base de datos, todas las conexiones de PostgreSQL pueden quedar ocupadas simultáneamente.

Estadística: Según datos de Trigger.dev, más del 40% de las instancias de producción self-hosted tienen tasks sin maxDuration configurado, lo que provoca bloqueo silencioso de workers.

B4 - Secrets y variables de entorno

Implementación

Las tareas de Trigger.dev se ejecutan en un entorno separado. Los secrets deben pasarse explícitamente a través de la configuración de Docker Compose.

# .env.trigger (nur auf dem Server, nicht im Git)
TRIGGER_DB_PASSWORD=...
MAGIC_LINK_SECRET=...
SESSION_SECRET=...
ENCRYPTION_KEY=...

# Secrets für eure Tasks (werden an Worker weitergereicht)
SUPABASE_SERVICE_ROLE_KEY=eyJ...
DATABASE_URL=postgresql://...
SENDGRID_API_KEY=SG.xxx
OPENAI_API_KEY=sk-xxx
STRIPE_SECRET_KEY=sk_live_xxx

Los secrets de las tareas se pasan en el setup self-hosted a través del Dashboard de Trigger.dev (Environment Variables) o del entorno Docker a los contenedores de workers.

En el código:

// RICHTIG: Environment Variable
const apiKey = process.env.SENDGRID_API_KEY

// FALSCH: Hardcoded
const apiKey = 'SG.xxx...'

Condición verificable

# Hardcoded Secrets?
grep -rn "sk_live\|sk_test\|SG\.\|sk-\|Bearer ey" \
  trigger/ --include="*.ts"
# Erwartung: keine Treffer

# .env.trigger nicht im Git?
git ls-files .env.trigger
# Erwartung: leer

# Alle benötigten Env Vars gesetzt?
for var in SUPABASE_SERVICE_ROLE_KEY SENDGRID_API_KEY; do
  if [ -z "${!var}" ]; then
    echo "FEHLT: $var"
  fi
done

Escenario de fallo

Los secrets en archivos de tareas acaban en el repositorio Git y en el artefacto de compilación (imagen del Worker). En el setup self-hosted, las imágenes de los workers se construyen y almacenan localmente. Los secrets hardcoded en estas imágenes son visibles para cualquiera con acceso al host Docker o al registro.

B5 - Manejo de errores y logging

Implementación

Las tareas deben gestionar los errores de forma limpia y no registrar datos sensibles.

import { task, logger } from '@trigger.dev/sdk/v3'

export const processOrder = task({
  id: 'process-order',

  retry: {
    maxAttempts: 3,
    factor: 2,
    minTimeoutInMs: 1_000,
    maxTimeoutInMs: 30_000,
  },

  run: async (payload: { orderId: string }) => {
    // Trigger.dev Logger (strukturiert, im Dashboard sichtbar)
    logger.info('Processing order', { orderId: payload.orderId })

    try {
      const result = await processOrderInternal(payload.orderId)

      logger.info('Order processed', {
        orderId: payload.orderId,
        status: result.status,
      })

      return result

    } catch (error) {
      // Strukturiert loggen (ohne sensible Details)
      logger.error('Order processing failed', {
        orderId: payload.orderId,
        errorMessage: error instanceof Error ? error.message : 'Unknown error',
        // NICHT: error.stack, payload Details, User-Daten
      })

      // Fehler weiterwerfen, damit Retry greift
      throw error
    }
  },

  // onFailure Hook: wird nach allen Retries aufgerufen
  onFailure: async (payload, error, params) => {
    logger.error('Order permanently failed after all retries', {
      orderId: payload.orderId,
      attemptNumber: params.run.attemptNumber,
    })

    // Alert senden (z.B. Slack Notification)
    await sendAlertToOps({
      task: 'process-order',
      orderId: payload.orderId,
      error: error.message,
    })
  },
})

Lo que NO debe registrarse:

// FALSCH
logger.info('Processing user', { user })             // vollständiges User-Objekt
logger.info('API call', { headers: req.headers })     // Auth-Header mit Token
logger.info('DB query', { connectionString })         // Datenbank-URL
console.log(process.env.SUPABASE_SERVICE_ROLE_KEY)    // Secret

Condición verificable

# console.log statt logger?
grep -rn "console\.log\|console\.error" trigger/tasks/ --include="*.ts"
# Erwartung: keine Treffer (immer logger von @trigger.dev/sdk verwenden)

# Sensible Daten in Logs?
grep -rn "logger\.\(info\|error\|warn\)" trigger/ --include="*.ts" | \
  grep -iE "password|secret|key|token|email.*:" | \
  grep -v "orderId\|taskId\|runId"
# Erwartung: keine Treffer

Escenario de fallo

Trigger.dev almacena todos los logs y los muestra en el Dashboard. Si console.log(user) registra un objeto de usuario completo con correo electrónico y metadatos, estos datos son visibles en el Dashboard de Trigger.dev para cualquiera con acceso, incluso meses después, ya que el historial de ejecuciones es persistente.

Parte C - Operación y monitorización

C1 - Monitorización y alertas

Implementación

Trigger.dev tiene un Dashboard integrado con historial de ejecuciones, logs y trazas. Adicionalmente, deben monitorizarse activamente estas métricas:

Kritische Metriken:
  - Failed Runs (letzte 24h)        -> Alert wenn > 5
  - Queue Length                     -> Alert wenn > 100
  - Average Run Duration             -> Alert wenn > 2x Baseline
  - Runs in WAITING State            -> Info, kein sofortiger Alert

Asegurar el Dashboard self-hosted:

# Das Trigger.dev Dashboard darf NICHT öffentlich erreichbar sein
# Zugang nur über:
# - Reverse Proxy mit Basic Auth
# - VPN / WireGuard
# - SSH Tunnel

# Beispiel: SSH Tunnel zum Dashboard
ssh -L 3040:localhost:3040 deploy@trigger-server

# Dann im Browser: http://localhost:3040

Health Check automatizado:

#!/bin/bash
# scripts/check-trigger-health.sh

# Trigger.dev Platform erreichbar?
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3040/healthcheck)
if [ "$HEALTH" != "200" ]; then
  echo "KRITISCH: Trigger.dev Platform nicht erreichbar"
fi

# Worker-Container läuft?
WORKER_STATUS=$(docker compose -f docker-compose.trigger.yml ps trigger-worker --format '{{.State}}')
if [ "$WORKER_STATUS" != "running" ]; then
  echo "KRITISCH: Trigger.dev Worker nicht running ($WORKER_STATUS)"
fi

# Failed Runs der letzten 24h (über Trigger.dev DB)
FAILED=$(docker compose -f docker-compose.trigger.yml exec -T trigger-db \
  psql -U postgres -d trigger -t -c \
  "SELECT count(*) FROM task_runs WHERE status = 'FAILED' AND created_at > now() - interval '24 hours';" 2>/dev/null)

if [ "${FAILED:-0}" -gt 5 ]; then
  echo "WARNUNG: $FAILED fehlgeschlagene Runs in den letzten 24h"
fi

Condición verificable

# Dashboard nur intern erreichbar?
curl -s -o /dev/null -w "%{http_code}" https://trigger.example.com:3040
# Erwartung: Connection refused oder 401

# Health Check Script läuft täglich?
crontab -l | grep "check-trigger-health"
# Erwartung: Eintrag vorhanden

C2 - Manejo de Dead Letters

Implementación

Las tareas que fallan después de todos los reintentos quedan en estado FAILED. Deben gestionarse activamente.

import { task, logger } from '@trigger.dev/sdk/v3'
import { createTaskClient } from '../lib/supabase'

export const criticalTask = task({
  id: 'critical-task',

  retry: {
    maxAttempts: 5,
    factor: 2,
    minTimeoutInMs: 2_000,
    maxTimeoutInMs: 60_000,
  },

  run: async (payload: { orderId: string }) => {
    // ... Task-Logik
  },

  onFailure: async (payload, error, params) => {
    const supabase = createTaskClient()

    // Failed Job in eigene Tabelle schreiben
    await supabase
      .from('failed_jobs')
      .insert({
        task_id: 'critical-task',
        payload: JSON.stringify(payload),
        error_message: error.message,
        attempts: params.run.attemptNumber,
        failed_at: new Date().toISOString(),
        resolved: false,
      })

    // Alert
    logger.error('Critical task permanently failed', {
      orderId: payload.orderId,
      attempts: params.run.attemptNumber,
    })
  },
})
-- Migration: failed_jobs Tabelle
CREATE TABLE IF NOT EXISTS failed_jobs (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  task_id TEXT NOT NULL,
  payload JSONB NOT NULL,
  error_message TEXT,
  attempts INTEGER,
  failed_at TIMESTAMPTZ NOT NULL,
  resolved BOOLEAN DEFAULT false,
  resolved_at TIMESTAMPTZ,
  resolved_by TEXT
);

-- RLS: nur service_role darf zugreifen
ALTER TABLE failed_jobs ENABLE ROW LEVEL SECURITY;
-- Keine Policy für anon/authenticated = kein Zugriff über PostgREST

Condición verificable

# Gibt es unresolvierte Failed Jobs?
# Über Supabase Client oder direkte DB-Query
psql -c "SELECT count(*) FROM failed_jobs WHERE resolved = false;"
# Erwartung: 0 (oder aktiv in Bearbeitung)

# Haben alle kritischen Tasks einen onFailure Hook?
grep -L "onFailure" trigger/tasks/*.ts
# Erwartung: nur unkritische Tasks ohne onFailure

C3 - Integración con Claude Code

Arquitectura

Git Push / PR
   |
   +-- Deterministische Checks (CI/CD)
   |   +-- Alle Tasks haben maxDuration?
   |   +-- Alle Tasks haben concurrencyLimit?
   |   +-- Externe API-Calls haben Idempotency Keys?
   |   +-- Keine hardcoded Secrets?
   |   +-- Kein console.log (nur logger)?
   |
   +-- Claude Code Analyse (wöchentlich oder bei PR)
       +-- Neue Tasks ohne Retry-Strategie?
       +-- DB-Zugriff korrekt eingeschränkt?
       +-- Idempotenz-Patterns konsistent?
       +-- onFailure für kritische Tasks vorhanden?
       +-- Concurrency Limits angemessen?

Script CI

#!/bin/bash
# scripts/check-trigger-tasks.sh

REPORT=""

# 1. Tasks ohne maxDuration
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if ! grep -q "maxDuration" "$file"; then
    REPORT+="WARNUNG: $name hat kein maxDuration\n"
  fi
done

# 2. Tasks ohne Concurrency
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if ! grep -qE "concurrencyLimit|queue:" "$file"; then
    REPORT+="WARNUNG: $name hat kein Concurrency Limit\n"
  fi
done

# 3. Externe API-Calls ohne Idempotency
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if grep -qE "fetch\(|stripe\.|resend\." "$file"; then
    if ! grep -qE "idempotencyKey|idempotencyKeys" "$file"; then
      REPORT+="WARNUNG: $name hat externe API-Calls ohne Idempotency\n"
    fi
  fi
done

# 4. Hardcoded Secrets
SECRETS=$(grep -rn "sk_live\|sk_test\|SG\.\|sk-" trigger/ --include="*.ts" 2>/dev/null)
if [ -n "$SECRETS" ]; then
  REPORT+="KRITISCH: Hardcoded Secrets:\n$SECRETS\n\n"
fi

# 5. console.log statt logger
CONSOLE=$(grep -rn "console\.\(log\|error\|warn\)" trigger/tasks/ --include="*.ts" 2>/dev/null)
if [ -n "$CONSOLE" ]; then
  REPORT+="WARNUNG: console.log statt Trigger.dev logger:\n$CONSOLE\n\n"
fi

# 6. Nicht exportierte Tasks
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if ! grep -q "export const" "$file"; then
    REPORT+="KRITISCH: $name Task ist nicht exportiert\n"
  fi
done

if [ -n "$REPORT" ]; then
  echo -e "=== Trigger.dev Task Security Check ===\n$REPORT"
else
  echo "Alle Trigger.dev Checks bestanden."
fi

Claude no ejecuta cambios automáticos en producción.

Lista de verificación de despliegue

Comprobar antes de cada despliegue de tareas de Trigger.dev:

Arquitectura
  [ ] Trigger.dev se ejecuta como servicio independiente (no en el proceso de Next.js)
  [ ] Instancia de PostgreSQL propia (no la BD de Supabase)
  [ ] Dashboard accesible solo internamente

Definición de tareas
  [ ] Cada tarea está exportada
  [ ] Cada tarea tiene un ID único
  [ ] Cada tarea tiene maxDuration
  [ ] Cada tarea tiene concurrencyLimit o shared Queue
  [ ] Cada tarea tiene configuración de retry

Idempotencia
  [ ] Las llamadas de trigger tienen idempotencyKey donde sea necesario
  [ ] Las llamadas a APIs externas dentro de las tareas tienen Idempotency
  [ ] Las operaciones de BD son idempotentes (upsert en lugar de insert donde sea posible)

Acceso a la base de datos
  [ ] service_role solo a través de createTaskClient()
  [ ] Las consultas acceden solo a los datos necesarios (sin SELECT *)
  [ ] Connection Pool limitado (max: 5-10)

Secrets
  [ ] Sin secrets hardcoded en el código
  [ ] Secrets a través de Environment Variables / Dashboard
  [ ] .env.trigger no está en Git

Logging
  [ ] Trigger.dev logger en lugar de console.log
  [ ] Sin datos sensibles en los logs (objetos de usuario, tokens, keys)

Manejo de errores
  [ ] Las tareas críticas tienen hook onFailure
  [ ] Los Failed Jobs se almacenan en la tabla failed_jobs
  [ ] Alertas configuradas para errores permanentes

Conclusión

Trigger.dev constituye la capa de procesamiento en segundo plano del stack. Utilizado correctamente, procesa jobs asíncronos de forma fiable, con reintentos, idempotencia y monitorización.

El punto de seguridad más crítico es el acceso a la base de datos: las tareas tienen típicamente más permisos que las solicitudes normales de usuario, porque trabajan con service_role o conexiones directas a la base de datos. Por eso, la validación de entrada, la limitación de alcance y la idempotencia deben implementarse conscientemente en cada tarea.

La combinación de la lógica de reintentos integrada de Trigger.dev, Concurrency Limits conscientes y análisis contextual de Claude Code conduce a workflows de Background estables que funcionan correctamente incluso ante fallos parciales.

Quien aplique estos principios junto con una arquitectura Cert-Ready-by-Design construye seguridad verificable en lugar de auditorías posteriores.

Descarga de la lista de verificación

Prompt preparado para Claude Code. Suba el archivo a su servidor e inicie Claude Code en el directorio del proyecto de su configuración Trigger.dev. Claude Code verificará automáticamente todos los puntos de seguridad de este runbook e informará APROBADO, ADVERTENCIA o CRÍTICO.

claude -p "$(cat claude-check-artikel-4-trigger-dev-es.md)" --allowedTools Read,Grep,Glob,Bash

Descargar checklist

Índice de la serie

Este artículo forma parte de nuestra serie DevOps para app stacks self-hosted.

  1. Supabase Self-Hosting Runbook
  2. Next.js sobre Supabase de forma segura
  3. Supabase Edge Functions de forma segura
  4. Trigger.dev Background Jobs de forma segura - este artículo
  5. Claude Code como control de seguridad en el workflow DevOps
  6. Security Baseline para todo el stack

El próximo artículo describe cómo se utiliza Claude Code como control de seguridad en el workflow DevOps - como la capa de análisis transversal sobre todos los artículos anteriores.

Bert Gogolin

Bert Gogolin

Director General, Gosign

AI Governance Briefing

IA empresarial, regulación e infraestructura - una vez al mes, directamente de mi parte.

Sin spam. Cancelable en cualquier momento. Política de privacidad

Trigger.dev Background Jobs DevOps Self-Hosting Security
Compartir este artículo

Preguntas frecuentes

¿Por qué los Background Jobs no deben ejecutarse en Next.js Server Actions?

Las Server Actions bloquean el servidor web de Next.js. Una tarea de IA que necesita 5 minutos ocupa un hilo de worker. Con pocos hilos de worker, toda la aplicación deja de responder para los usuarios.

¿Por qué Trigger.dev necesita su propia instancia de PostgreSQL?

Trigger.dev almacena el estado de la cola, el historial de ejecuciones y el estado de los workers en su base de datos. Son datos diferentes a los datos de la aplicación en la PostgreSQL de Supabase. Compartir la base de datos genera conflictos en las conexiones.

¿Qué sucede en un retry sin clave de idempotencia?

Si un task se re-ejecuta después de un fallo parcial y no tiene clave de idempotencia, las llamadas API externas se repiten. Esto puede provocar pagos duplicados en Stripe, emails duplicados o entradas duplicadas en la base de datos. El sistema integrado de idempotencyKeys de Trigger.dev previene esto.