Pular para o conteúdo
Infraestrutura & Tecnologia

Trigger.dev Background Jobs com segurança

Runbook DevOps para Trigger.dev v3: setup Self-Hosted, definição de tasks, idempotência, concurrency, secrets e integração com Claude Code.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 18 min de leitura

Assim que uma aplicação vai além de operações CRUD simples, surgem tarefas que não devem rodar de forma síncrona no ciclo request-response: envio de e-mails, processamento de webhooks, jobs de importação/exportação, tarefas de IA, geração de PDF, migração de dados e tarefas periódicas.

Essas tarefas não pertencem a Next.js Server Actions (bloqueiam o servidor web), não pertencem a Supabase Edge Functions (limite de timeout, sem processos de longa duração) e também não pertencem a Cron Jobs no servidor (sem lógica de retry, sem monitoramento).

Elas pertencem a uma camada de jobs dedicada. No nosso stack, o Trigger.dev assume esse papel.

Este runbook descreve como operar o Trigger.dev com segurança no stack. Cada passo contém uma implementação concreta com a API atual do Trigger.dev v3, uma condição verificável e um cenário de falha.

Nota sobre a arquitetura: O Trigger.dev consiste em duas partes: a Platform (Webapp, Dashboard, Queue Management) e o Worker (executa as suas tasks). Operamos ambos como self-hosted na nossa própria infraestrutura. Este runbook descreve exclusivamente o setup Self-Hosted com Trigger.dev v3.

Resumo - Artigo 4 de 6 da série DevOps Runbook

  • Trigger.dev como stack Docker separado com seu próprio PostgreSQL
  • Cada task precisa de maxDuration, concurrencyLimit e configuração de retry
  • Chaves de idempotência em chamadas de API externas
  • Acesso ao banco de dados limitado aos campos necessários
  • Logger do Trigger.dev em vez de console.log

Sumário da série

Este guia faz parte da nossa série de runbooks DevOps para stacks de aplicações self-hosted.

  1. Supabase Self-Hosting Runbook
  2. Next.js sobre Supabase com segurança
  3. Supabase Edge Functions com segurança
  4. Trigger.dev Background Jobs com segurança - este artigo
  5. Claude Code como controle de segurança no workflow DevOps
  6. Security Baseline para todo o stack

O artigo 1 descreve a plataforma. O artigo 2 descreve a camada de aplicação. O artigo 3 descreve integrações. Este artigo descreve processamento assíncrono de jobs.

Visão geral da arquitetura

Browser
   |
Next.js (camada de aplicação)
   |
   +-- 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 ou conexão direta com o DB)
       |
       PostgreSQL

Regras fundamentais:

User Request       -> Next.js Server Action / Route Handler
Evento curto       -> Supabase Edge Function
Longa duração      -> Trigger.dev Task
Periódico          -> Trigger.dev Scheduled Task

Guia de configuração de tasks

Tipo de taskmaxDurationconcurrencyLimitEstratégia de Retry
Envio de email30s5-103 tentativas, exponential backoff
Geração de PDF120s2-32 tentativas, intervalo fixo
Inferência AI (LLM)300s1-32 tentativas, exponential backoff
Migração de dados600s11 tentativa, tratamento manual
Processamento de webhooks30s103 tentativas, exponential backoff

Ponto crítico de segurança

Trigger.dev Tasks tipicamente têm acesso total ao banco de dados sem RLS. Eles se conectam via service_role Key (pelo Supabase Client) ou via conexão PostgreSQL direta (pelo DATABASE_URL). Em ambos os casos, as Row Level Security Policies não se aplicam. Essa é a diferença mais importante em relação a requests que passam pelo PostgREST com a chave anon.

Quem conhece os fundamentos de gerenciamento de secrets entende por que essa separação é essencial.

Parte A - Decisões de arquitetura

A1 - Operar Trigger.dev v3 self-hosted como serviço próprio

Implementação

O Trigger.dev roda separado do Next.js e do Supabase como seu próprio Docker Stack. A arquitetura Self-Hosted v3 consiste em três componentes:

Next.js App (seu servidor Locaweb / Magalu Cloud)
   |
   +-- Trigger.dev Platform (Webapp + Queue + Dashboard)
           |
           +-- Trigger.dev Worker (executa suas tasks)
           |
           +-- Trigger.dev PostgreSQL (instância própria de DB)
# 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: O Trigger.dev precisa da sua própria instância PostgreSQL na porta 5433. Não compartilhe o banco de dados do Supabase (porta 5432). O Trigger.dev armazena status de fila, histórico de execuções, estado dos workers e metadados no seu banco. São dados diferentes dos dados da sua aplicação.

Configurar o SDK no projeto 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

Worker Deployment:

No setup self-hosted v3, o deploy das tasks é feito pela CLI, que se comunica com a sua 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: O Worker Docker v3 utiliza Docker-in-Docker (Socket Mounting). Isso significa que a Trigger.dev Platform precisa de acesso ao Docker Socket (/var/run/docker.sock). Isso tem implicações de segurança: um container Trigger.dev comprometido poderia iniciar containers Docker arbitrários. Por isso, o Trigger.dev deveria idealmente rodar em um servidor próprio ou em uma rede Docker isolada.

Condição verificável

# 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

Cenário de falha

Quando o Trigger.dev roda no mesmo processo que o Next.js, tasks de longa duração bloqueiam o servidor web. Uma tarefa de IA que leva 5 minutos ocupa um worker do Next.js. Com poucos worker threads (padrão: 1 por CPU), toda a aplicação se torna irresponsiva para os usuários. Se o Trigger.dev compartilha o banco do Supabase, queries da fila de jobs competem com requests de usuários pelas conexões do banco. Se o Docker Socket é montado sem isolamento de rede, uma task comprometida pode iniciar containers que acessam a rede do host.

A2 - Controlar acesso ao banco de dados a partir das tasks

Implementação

As tasks precisam de acesso aos dados da sua aplicação no Supabase. Existem dois caminhos:

Caminho 1: Supabase Client com 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!
  )
}

Vantagem: passa pelo PostgREST, utiliza a camada de API do Supabase. Desvantagem: ignora o RLS (service_role).

Caminho 2: Conexão direta com o DB (para queries complexas)

// 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 }

Desvantagem: ignora tanto o RLS quanto o PostgREST. Acesso total ao banco.

Regra: Independentemente do caminho escolhido, a task tem mais permissões do que um request normal de usuário. Por isso, cada task deve definir seu próprio escopo de forma clara.

// 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('*')

Quem conhece os patterns de RLS do runbook de Supabase Self-Hosting entende por que a diferença de escopo é crítica.

Condição verificável

# 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?

Cenário de falha

Uma task com acesso direto ao banco e input não validado pode levar a SQL Injection. Uma task que faz SELECT * em tabelas grandes pode comprometer a performance do banco para todos os usuários. Um connection pool sem controle (sem limite max) pode ocupar todas as conexões PostgreSQL disponíveis e paralisar toda a aplicação.

Parte B - Verificações de implementação

Estas regras valem para cada task e devem ser conferidas a cada deployment.

B1 - Definir tasks corretamente (API v3)

Implementação

O Trigger.dev v3 utiliza a função task() do @trigger.dev/sdk/v3. Cada task é exportada e tem um 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 task a partir do 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 }
}

Condição verificável

# 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)

Cenário de falha

Uma task sem export não é reconhecida pelo Trigger.dev e desaparece no deployment sem mensagem de erro. Uma task sem configuração de retry usa o padrão (sem retry), de modo que um erro temporário de API (por exemplo, timeout do SendGrid) resulta na perda permanente do e-mail.

B2 - Garantir idempotência

Implementação

O Trigger.dev possui um sistema de idempotência integrado via idempotencyKey. Isso é preferível ao check findUnique feito manualmente.

Ao disparar (evita acionamento duplicado):

// 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 da task (evita efeitos colaterais duplicados em retries):

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 }
  },
})

Condição verificável

# 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

Cenário de falha

Sem proteção de idempotência ao disparar, um webhook duplicado (Stripe faz retry em caso de timeout) pode acionar a mesma task duas vezes. Sem idempotência dentro da task, um retry após um erro parcial pode causar pagamentos duplicados ou e-mails duplicados.

B3 - Configurar timeouts e concurrency corretamente

Implementação

O Trigger.dev v3 possui maxDuration (limite de tempo de execução por task) e concurrencyLimit (execuções paralelas). Ambos devem ser definidos conscientemente para cada task.

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 referência:

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

Condição verificável

# 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

Cenário de falha

Sem maxDuration, uma task pode rodar infinitamente em caso de uma chamada de API travada e bloquear permanentemente um slot de worker. Sem concurrencyLimit, 100 tasks de e-mail disparadas simultaneamente podem sobrecarregar o provedor SMTP e causar rate limiting. Sem concurrency limit em tasks intensivas de banco de dados, todas as conexões PostgreSQL podem ser ocupadas ao mesmo tempo.

Estatística: De acordo com dados do Trigger.dev, mais de 40% das instâncias de produção self-hosted possuem tasks sem maxDuration configurado - levando ao bloqueio silencioso de workers.

B4 - Secrets e variáveis de ambiente

Implementação

Trigger.dev Tasks rodam em um ambiente separado. Os secrets devem ser passados explicitamente pela configuração do 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

Os secrets das tasks são passados no setup self-hosted pelo Dashboard do Trigger.dev (Environment Variables) ou pelo ambiente Docker para os containers de worker.

No código:

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

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

Condição verificável

# 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

Cenário de falha

Secrets em arquivos de task acabam no repositório Git e no build artifact (Worker Image). No setup self-hosted, as Worker Images são construídas e armazenadas localmente. Secrets hardcoded nessas imagens ficam visíveis para qualquer pessoa com acesso ao Docker Host ou ao registry.

B5 - Error handling e logging

Implementação

As tasks devem tratar erros de forma limpa e não registrar dados sensíveis nos logs.

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,
    })
  },
})

O que NÃO deve ser registrado nos logs:

// 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

Condição verificável

# 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

Cenário de falha

O Trigger.dev armazena todos os logs e os exibe no Dashboard. Se console.log(user) registra um objeto de usuário completo com e-mail e metadados, esses dados ficam visíveis no Dashboard do Trigger.dev para qualquer pessoa com acesso ao Dashboard, mesmo meses depois, já que o histórico de execuções é persistente.

Parte C - Operação e monitoramento

C1 - Monitoramento e alertas

Implementação

O Trigger.dev possui um Dashboard integrado com histórico de execuções, logs e traces. Além disso, estas métricas devem ser monitoradas ativamente:

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

Proteger o 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

Condição verificável

# 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 - Dead Letter Handling

Implementação

Tasks que falham após todos os retries ficam no status FAILED. Elas devem ser tratadas ativamente.

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

Condição verificável

# 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 - Integração com Claude Code

Arquitetura

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

O Claude não executa alterações automáticas em produção.

Checklist de deployment

Verificar antes de cada deployment de Trigger.dev Tasks:

Arquitetura
  [ ] Trigger.dev roda como serviço próprio (não no processo Next.js)
  [ ] Instância PostgreSQL própria (não o banco do Supabase)
  [ ] Dashboard acessível apenas internamente

Definição de tasks
  [ ] Cada task é exportada
  [ ] Cada task tem um ID único
  [ ] Cada task tem maxDuration
  [ ] Cada task tem concurrencyLimit ou shared Queue
  [ ] Cada task tem configuração de retry

Idempotência
  [ ] Chamadas de trigger têm idempotencyKey quando necessário
  [ ] Chamadas de API externas dentro das tasks têm Idempotency
  [ ] Operações de banco são idempotentes (upsert em vez de insert quando possível)

Acesso ao banco de dados
  [ ] service_role apenas via createTaskClient()
  [ ] Queries acessam apenas os dados necessários (sem SELECT *)
  [ ] Connection pool limitado (max: 5-10)

Secrets
  [ ] Nenhum secret hardcoded no código
  [ ] Secrets via Environment Variables / Dashboard
  [ ] .env.trigger não está no Git

Logging
  [ ] Trigger.dev logger em vez de console.log
  [ ] Nenhum dado sensível nos logs (objetos de usuário, tokens, keys)

Error handling
  [ ] Tasks críticas possuem hook onFailure
  [ ] Failed jobs são armazenados na tabela failed_jobs
  [ ] Alertas configurados para falhas permanentes

Conclusão

O Trigger.dev forma a camada de processamento em background no stack. Usado corretamente, ele processa jobs assíncronos de forma confiável, com retries, idempotência e monitoramento.

O ponto de segurança mais crítico é o acesso ao banco de dados: tasks tipicamente têm mais permissões do que requests normais de usuários, pois trabalham com service_role ou conexão direta com o banco. Por isso, validação de input, delimitação de escopo e idempotência devem ser implementadas conscientemente em cada task.

A combinação da lógica de retry integrada do Trigger.dev, limites de concurrency conscientes e análise contextual do Claude Code resulta em workflows de background estáveis que funcionam corretamente mesmo em caso de falhas parciais.

Quem segue esses princípios junto com uma arquitetura Cert-Ready-by-Design constrói segurança verificável em vez de auditorias posteriores.

Download da checklist de auditoria

Prompt preparado para o Claude Code. Faça upload do arquivo no seu servidor e inicie o Claude Code no diretório do projeto da sua configuração Trigger.dev. O Claude Code verificará automaticamente todos os pontos de segurança deste runbook e reportará APROVADO, AVISO ou CRÍTICO.

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

Baixar checklist

Sumário da série

  1. Supabase Self-Hosting Runbook
  2. Next.js sobre Supabase com segurança
  3. Supabase Edge Functions com segurança
  4. Trigger.dev Background Jobs com segurança - este artigo
  5. Claude Code como controle de segurança no workflow DevOps
  6. Security Baseline para todo o stack

O próximo artigo descreve como o Claude Code é utilizado como controle de segurança no workflow DevOps - como a camada de análise transversal sobre todos os artigos anteriores.

Bert Gogolin

Bert Gogolin

Diretor Executivo, Gosign

AI Governance Briefing

IA empresarial, regulamentação e infraestrutura - uma vez por mês, diretamente de mim.

Sem spam. Cancelável a qualquer momento. Política de privacidade

Trigger.dev Background Jobs DevOps Self-Hosting Security
Compartilhar este artigo

Perguntas frequentes

Por que Background Jobs não pertencem a Next.js Server Actions?

Server Actions bloqueiam o servidor web do Next.js. Uma tarefa de IA que leva 5 minutos ocupa um worker thread. Com poucos worker threads, toda a aplicação se torna irresponsiva para os usuários.

Por que o Trigger.dev precisa de uma instância PostgreSQL própria?

O Trigger.dev armazena status de fila, histórico de execuções e estado dos workers no seu banco de dados. São dados diferentes dos dados da aplicação no PostgreSQL do Supabase. O uso compartilhado causa conflitos nas conexões do banco de dados.

O que acontece em um retry sem chave de idempotência?

Se um task é re-executado após uma falha parcial e não possui chave de idempotência, as chamadas de API externas são repetidas. Isso pode levar a pagamentos duplicados no Stripe, emails duplicados ou entradas duplicadas no banco de dados. O sistema integrado de idempotencyKeys do Trigger.dev previne isso.