Zum Inhalt springen
Infrastruktur & Technologie

Trigger.dev Background Jobs sicher betreiben

DevOps-Runbook für Trigger.dev v3: Self-Hosted Setup, Task-Definition, Idempotenz, Concurrency, Secrets und Claude-Code-Integration.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 18 Min. Lesezeit

Sobald eine Anwendung über einfache CRUD-Operationen hinausgeht, entstehen Aufgaben, die nicht synchron im Request-Response-Zyklus laufen sollten: E-Mail-Versand, Webhook-Verarbeitung, Import/Export Jobs, AI-Tasks, PDF-Generierung, Datenmigration und periodische Aufgaben.

Diese Aufgaben gehören nicht in Next.js Server Actions (blockieren den Webserver), nicht in Supabase Edge Functions (Timeout-Limit, keine Langläufer) und auch nicht in Cron-Jobs auf dem Server (keine Retry-Logik, kein Monitoring).

Sie gehören in eine dedizierte Job-Schicht. Studien zeigen, dass 35% aller Background-Job-Ausfälle durch fehlende Idempotenz bei Retries verursacht werden (Temporal.io Reliability Report 2024). In unserem Stack übernimmt Trigger.dev diese Rolle.

Dieses Runbook beschreibt, wie Trigger.dev sicher im Stack betrieben wird. Jeder Schritt enthält eine konkrete Implementierung mit der aktuellen Trigger.dev v3 API, eine prüfbare Bedingung und ein Failure Scenario.

Hinweis zur Architektur: Trigger.dev besteht aus zwei Teilen: der Platform (Webapp, Dashboard, Queue Management) und dem Worker (führt eure Tasks aus). Wir betreiben beides self-hosted auf unserer eigenen Infrastruktur. Dieses Runbook beschreibt ausschließlich das Self-Hosted Setup mit Trigger.dev v3.

Auf einen Blick - Artikel 4 von 6 der DevOps-Runbook-Serie

  • Trigger.dev als eigener Docker-Stack mit separater PostgreSQL-Instanz
  • Jeder Task braucht maxDuration, concurrencyLimit und Retry-Konfiguration
  • Idempotency Keys bei allen externen API-Calls (Stripe, SendGrid, etc.)
  • Datenbankzugriff über service_role nur auf benötigte Felder einschränken
  • Trigger.dev logger statt console.log (Logs sind persistent im Dashboard)

Serien-Inhaltsverzeichnis

Diese Anleitung ist Teil unserer DevOps-Runbook-Serie für self-hosted App-Stacks.

  1. Supabase Self-Hosting Runbook
  2. Next.js über Supabase sicher betreiben
  3. Supabase Edge Functions sicher einsetzen
  4. Trigger.dev Background Jobs sicher betreiben - dieser Artikel
  5. Claude Code als Sicherheitskontrolle im DevOps-Workflow
  6. Security Baseline für den gesamten Stack

Artikel 1 beschreibt die Plattform mit der Server-Infrastruktur. Artikel 2 beschreibt die App-Schicht mit den Mutation-Patterns. Artikel 3 beschreibt Integrationen und die Delegation von Langläufern. Dieser Artikel beschreibt asynchrone Job-Verarbeitung.

Architekturüberblick

Browser
   |
Next.js (App-Schicht)
   |
   +-- tasks.trigger("send-email", payload)    <- Job starten
   |
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

Grundregeln:

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

Kritischer Sicherheitspunkt

Trigger.dev Tasks haben typischerweise vollen Datenbankzugriff ohne RLS. Sie verbinden sich entweder über den service_role Key (via Supabase Client) oder über eine direkte PostgreSQL-Connection (via DATABASE_URL). In beiden Fällen greifen Row Level Security Policies nicht. Das ist der wichtigste Unterschied zu Requests, die über PostgREST mit dem anon Key laufen.

Wer die Grundlagen der Secret-Verwaltung kennt, versteht warum diese Trennung essenziell ist.

Richtwerte für Task-Konfiguration

Task-TypmaxDurationconcurrencyLimitRetry-Strategie
E-Mail senden30s5-103x, exponential
PDF generieren120s2-33x, exponential
AI-Inference (LLM)300s1-32x, exponential
Datenbank-Migration600s11x, kein Retry
Webhook verarbeiten30s103x, exponential
CRM-Sync60s3-55x, exponential

Teil A - Architekturentscheidungen

A1 - Trigger.dev v3 self-hosted als eigenen Service betreiben

Umsetzung

Trigger.dev läuft getrennt von Next.js und Supabase als eigener Docker-Stack. Die Self-Hosted v3 Architektur besteht aus drei Komponenten:

Next.js App (euer Hetzner Server)
   |
   +-- 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

Wichtig: Trigger.dev braucht seine eigene PostgreSQL-Instanz auf Port 5433. Nicht die Supabase-Datenbank (Port 5432) mitnutzen. Trigger.dev speichert Queue-Status, Run-History, Worker-State und Metadata in seiner DB. Das sind andere Daten als eure Applikationsdaten.

SDK im Next.js Projekt konfigurieren:

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

Im self-hosted v3 Setup deployt ihr Tasks über die CLI, die mit eurer lokalen Platform kommuniziert:

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

# Lokale Entwicklung
npx trigger.dev@latest dev

Hinweis: Der v3 Docker-Worker nutzt Docker-in-Docker (Socket Mounting). Das bedeutet, die Trigger.dev Platform braucht Zugriff auf den Docker Socket (/var/run/docker.sock). Das hat Sicherheitsimplikationen: Ein kompromittierter Trigger.dev Container könnte beliebige Docker-Container starten. Deshalb läuft Trigger.dev idealerweise auf einem eigenen Server oder in einem isolierten Docker-Netzwerk.

Prüfbare Bedingung

# 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

Failure Scenario

Wenn Trigger.dev im selben Prozess wie Next.js läuft, blockieren lang laufende Tasks den Webserver. Ein AI-Task der 5 Minuten braucht, hält einen Next.js Worker besetzt. Bei wenigen Worker-Threads (Standard: 1 pro CPU) wird die gesamte App für User unresponsive. Wenn Trigger.dev die Supabase-DB mitnutzt, konkurrieren Job-Queue-Queries mit User-Requests um Datenbankverbindungen. Wenn der Docker Socket ohne Netzwerk-Isolation gemountet wird, kann ein kompromittierter Task Container starten die auf das Host-Netzwerk zugreifen.

A2 - Datenbankzugriff aus Tasks kontrollieren

Umsetzung

Tasks brauchen Zugriff auf eure Applikationsdaten in Supabase. Es gibt zwei Wege:

Weg 1: Supabase Client mit service_role (empfohlen)

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

Vorteil: Geht über PostgREST, nutzt die Supabase API-Schicht. Nachteil: Umgeht RLS (service_role).

Weg 2: Direkte DB-Connection (für komplexe Queries)

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

Nachteil: Umgeht sowohl RLS als auch PostgREST. Voller DB-Zugang.

Regel: Egal welcher Weg, der Task hat mehr Rechte als ein normaler User-Request. Deshalb muss jeder Task seinen eigenen Scope klar definieren.

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

Wer die RLS-Patterns aus dem Supabase Self-Hosting Runbook kennt, versteht warum der Scope-Unterschied kritisch ist.

Prüfbare Bedingung

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

Failure Scenario

Ein Task mit direktem DB-Zugriff und unvalidiertem Input kann zu SQL-Injection führen. Ein Task der SELECT * auf große Tabellen macht, kann die Datenbank-Performance für alle User beeinträchtigen. Ein unkontrollierter Connection Pool (kein max Limit) kann alle verfügbaren PostgreSQL-Connections belegen und die gesamte Applikation lahmlegen.

Teil B - Implementierungschecks

Diese Regeln gelten für jeden Task und müssen bei jedem Deployment geprüft werden.

B1 - Tasks korrekt definieren (v3 API)

Umsetzung

Trigger.dev v3 verwendet die task() Funktion aus @trigger.dev/sdk/v3. Jeder Task wird exportiert und hat eine eindeutige ID.

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

Task aus Next.js triggern

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

Prüfbare Bedingung

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

Failure Scenario

Ein Task ohne export wird von Trigger.dev nicht erkannt und verschwindet beim Deployment ohne Fehlermeldung. Ein Task ohne retry Konfiguration nutzt den Default (kein Retry), sodass ein temporärer API-Fehler (z.B. SendGrid Timeout) zum permanenten Verlust der E-Mail führt.

B2 - Idempotenz sicherstellen

Umsetzung

Trigger.dev hat ein eingebautes Idempotency-System über idempotencyKey. Das ist dem selbstgebauten findUnique Check vorzuziehen.

Beim Triggern (verhindert doppeltes Auslösen):

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

Innerhalb des Tasks (verhindert doppelte Seiteneffekte bei 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 }
  },
})

Prüfbare Bedingung

# 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

Failure Scenario

Ohne Idempotenz-Schutz beim Triggern kann ein doppelter Webhook (Stripe retried bei Timeout) denselben Task zweimal auslösen. Ohne Idempotenz innerhalb des Tasks kann ein Retry nach einem Teilfehler doppelte Zahlungen oder doppelte E-Mails verursachen.

B3 - Timeouts und Concurrency korrekt konfigurieren

Umsetzung

Trigger.dev v3 kennt maxDuration (Laufzeit-Limit pro Task) und concurrencyLimit (parallele Ausführungen). Beide müssen pro Task bewusst gesetzt werden.

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) => {
    // ...
  },
})

Richtwerte:

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

Prüfbare Bedingung

# 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

Failure Scenario

Ohne maxDuration kann ein Task bei einem hängenden API-Call unendlich laufen und einen Worker-Slot dauerhaft blockieren. Ohne concurrencyLimit können 100 gleichzeitig getriggerte E-Mail-Tasks den SMTP-Provider überlasten und zu Rate Limiting führen. Ohne Concurrency Limit auf DB-intensiven Tasks können alle PostgreSQL-Connections gleichzeitig belegt werden.

B4 - Secrets und Environment Variables

Umsetzung

Trigger.dev Tasks laufen in einer separaten Umgebung. Secrets müssen explizit über die Docker-Compose-Konfiguration übergeben werden.

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

Die Task-Secrets werden im self-hosted Setup über das Trigger.dev Dashboard (Environment Variables) oder über die Docker-Umgebung an die Worker-Container weitergegeben.

Im Code:

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

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

Prüfbare Bedingung

# 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

Failure Scenario

Secrets in Task-Dateien landen im Git-Repository und im Build-Artifact (Worker Image). Im self-hosted Setup werden Worker-Images lokal gebaut und gespeichert. Hardcoded Secrets in diesen Images sind für jeden mit Zugang zum Docker-Host oder zur Registry sichtbar.

B5 - Error Handling und Logging

Umsetzung

Tasks müssen Fehler sauber behandeln und keine sensiblen Daten loggen.

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

Was NICHT geloggt werden darf:

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

Prüfbare Bedingung

# 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

Failure Scenario

Trigger.dev speichert alle Logs und zeigt sie im Dashboard. Wenn console.log(user) ein vollständiges User-Objekt mit E-Mail und Metadaten loggt, sind diese Daten im Trigger.dev Dashboard für jeden mit Dashboard-Zugang sichtbar, auch Monate später, da Run-History persistent ist.

Teil C - Betrieb und Überwachung

C1 - Monitoring und Alerting

Umsetzung

Trigger.dev hat ein eingebautes Dashboard mit Run-History, Logs und Traces. Zusätzlich sollten diese Metriken aktiv überwacht werden:

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

Self-Hosted Dashboard absichern:

# 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

Automatisierter Health Check:

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

Prüfbare Bedingung

# 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

Umsetzung

Tasks die nach allen Retries fehlschlagen, landen im FAILED Status. Diese müssen aktiv bearbeitet werden.

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

Prüfbare Bedingung

# 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 - Claude Code Integration

Architektur

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?

CI-Script

#!/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 führt keine automatischen Änderungen auf Production aus.

Deployment-Checkliste

Vor jedem Deployment von Trigger.dev Tasks prüfen:

Architektur
  [ ] Trigger.dev läuft als eigener Service (nicht im Next.js Prozess)
  [ ] Eigene PostgreSQL-Instanz (nicht Supabase DB)
  [ ] Dashboard nur intern erreichbar

Task-Definition
  [ ] Jeder Task ist exportiert
  [ ] Jeder Task hat eine eindeutige ID
  [ ] Jeder Task hat maxDuration
  [ ] Jeder Task hat concurrencyLimit oder shared Queue
  [ ] Jeder Task hat retry Konfiguration

Idempotenz
  [ ] Trigger-Aufrufe haben idempotencyKey wo nötig
  [ ] Externe API-Calls innerhalb von Tasks haben Idempotency
  [ ] DB-Operationen sind idempotent (upsert statt insert wo möglich)

Datenbankzugriff
  [ ] service_role nur über createTaskClient()
  [ ] Queries greifen nur auf benötigte Daten zu (kein SELECT *)
  [ ] Connection Pool limitiert (max: 5-10)

Secrets
  [ ] Keine hardcoded Secrets im Code
  [ ] Secrets über Environment Variables / Dashboard
  [ ] .env.trigger nicht im Git

Logging
  [ ] Trigger.dev logger statt console.log
  [ ] Keine sensiblen Daten in Logs (User-Objekte, Tokens, Keys)

Error Handling
  [ ] Kritische Tasks haben onFailure Hook
  [ ] Failed Jobs werden in failed_jobs Tabelle gespeichert
  [ ] Alerting bei dauerhaften Fehlern konfiguriert

Fazit

Trigger.dev bildet die Background-Processing-Schicht im Stack. Richtig eingesetzt verarbeitet es asynchrone Jobs zuverlässig, mit Retries, Idempotenz und Monitoring.

Der kritischste Sicherheitspunkt ist der Datenbankzugriff: Tasks haben typischerweise mehr Rechte als normale User-Requests, weil sie mit service_role oder direkter DB-Connection arbeiten. Deshalb müssen Input Validation, Scope-Begrenzung und Idempotenz bei jedem Task bewusst implementiert werden.

Die Kombination aus der eingebauten Trigger.dev Retry-Logik, bewussten Concurrency Limits und kontextueller Claude Code Analyse führt zu stabilen Background-Workflows, die auch bei Teilausfällen korrekt funktionieren.

Wer diese Prinzipien zusammen mit einer Cert-Ready-by-Design-Architektur verfolgt, baut prüfbare Sicherheit statt nachträglicher Audits.

Audit-Checkliste als Download

Vorbereiteter Prompt für Claude Code. Laden Sie die Datei auf Ihren Server und starten Sie Claude Code im Projektverzeichnis Ihres Trigger.dev-Setups. Claude Code prüft automatisch alle Sicherheitspunkte aus diesem Runbook und meldet BESTANDEN, WARNUNG oder KRITISCH.

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

Checkliste herunterladen

Serien-Inhaltsverzeichnis

  1. Supabase Self-Hosting Runbook
  2. Next.js über Supabase sicher betreiben
  3. Supabase Edge Functions sicher einsetzen
  4. Trigger.dev Background Jobs sicher betreiben - dieser Artikel
  5. Claude Code als Sicherheitskontrolle im DevOps-Workflow
  6. Security Baseline für den gesamten Stack

Der nächste Artikel beschreibt, wie Claude Code als kontextuelle Analyse-Schicht im DevOps Workflow eingesetzt wird - als die übergreifende Sicherheitskontrolle über alle bisherigen Artikel hinweg.

Bert Gogolin

Bert Gogolin

Geschäftsführer, Gosign

AI Governance Briefing

Enterprise AI, Regulierung und Infrastruktur - einmal im Monat, direkt von mir.

Kein Spam. Jederzeit abbestellbar. Datenschutzerklärung

Trigger.dev Background Jobs DevOps Self-Hosting Security
Artikel teilen

Häufige Fragen

Warum gehören Background Jobs nicht in Next.js Server Actions?

Server Actions blockieren den Next.js Webserver. Ein AI-Task der 5 Minuten braucht, hält einen Worker-Thread besetzt. Bei wenigen Worker-Threads wird die gesamte App für Nutzer unresponsive.

Warum braucht Trigger.dev eine eigene PostgreSQL-Instanz?

Trigger.dev speichert Queue-Status, Run-History und Worker-State in seiner Datenbank. Das sind andere Daten als die Applikationsdaten in der Supabase-PostgreSQL. Eine gemeinsame Nutzung führt zu Konflikten bei Datenbankverbindungen.

Was passiert bei einem Retry ohne Idempotency Key?

Wenn ein Task nach einem Teilfehler erneut ausgeführt wird und keinen Idempotency Key hat, werden externe API-Calls wiederholt. Das kann zu doppelten Zahlungen bei Stripe, doppelten E-Mails oder doppelten Datenbankeinträgen führen. Trigger.dev's eingebautes idempotencyKeys-System verhindert das.