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.
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.
- Supabase Self-Hosting Runbook
- Next.js sobre Supabase de forma segura
- Supabase Edge Functions de forma segura
- Trigger.dev Background Jobs de forma segura - este artículo
- Claude Code como control de seguridad en el workflow DevOps
- 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 task | maxDuration | concurrencyLimit | Estrategia de Retry |
|---|---|---|---|
| Envío de email | 30s | 5-10 | 3 intentos, exponential backoff |
| Generación de PDF | 120s | 2-3 | 2 intentos, intervalo fijo |
| Inferencia AI (LLM) | 300s | 1-3 | 2 intentos, exponential backoff |
| Migración de datos | 600s | 1 | 1 intento, manejo manual |
| Procesamiento de webhooks | 30s | 10 | 3 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.
- Supabase Self-Hosting Runbook
- Next.js sobre Supabase de forma segura
- Supabase Edge Functions de forma segura
- Trigger.dev Background Jobs de forma segura - este artículo
- Claude Code como control de seguridad en el workflow DevOps
- 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
Director General, Gosign
AI Governance Briefing
IA empresarial, regulación e infraestructura - una vez al mes, directamente de mi parte.