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.
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.
- Supabase Self-Hosting Runbook
- Next.js über Supabase sicher betreiben
- Supabase Edge Functions sicher einsetzen
- Trigger.dev Background Jobs sicher betreiben - dieser Artikel
- Claude Code als Sicherheitskontrolle im DevOps-Workflow
- 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-Typ | maxDuration | concurrencyLimit | Retry-Strategie |
|---|---|---|---|
| E-Mail senden | 30s | 5-10 | 3x, exponential |
| PDF generieren | 120s | 2-3 | 3x, exponential |
| AI-Inference (LLM) | 300s | 1-3 | 2x, exponential |
| Datenbank-Migration | 600s | 1 | 1x, kein Retry |
| Webhook verarbeiten | 30s | 10 | 3x, exponential |
| CRM-Sync | 60s | 3-5 | 5x, 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 herunterladenSerien-Inhaltsverzeichnis
- Supabase Self-Hosting Runbook
- Next.js über Supabase sicher betreiben
- Supabase Edge Functions sicher einsetzen
- Trigger.dev Background Jobs sicher betreiben - dieser Artikel
- Claude Code als Sicherheitskontrolle im DevOps-Workflow
- 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
Geschäftsführer, Gosign
AI Governance Briefing
Enterprise AI, Regulierung und Infrastruktur - einmal im Monat, direkt von mir.