Running Trigger.dev Background Jobs Securely
DevOps runbook for Trigger.dev v3: self-hosted setup, task definition, idempotency, concurrency, secrets, and Claude Code integration.
Once an application goes beyond simple CRUD operations, tasks emerge that should not run synchronously in the request-response cycle: email delivery, webhook processing, import/export jobs, AI tasks, PDF generation, data migration, and periodic tasks.
These tasks do not belong in Next.js Server Actions (they block the web server), not in Supabase Edge Functions (timeout limits, no long-running tasks), and not in cron jobs on the server (no retry logic, no monitoring).
They belong in a dedicated job layer. In our stack, Trigger.dev fills this role.
This runbook describes how to operate Trigger.dev securely within the stack. Every step contains a concrete implementation using the current Trigger.dev v3 API, a verifiable condition, and a failure scenario.
At a Glance - Part 4 of 6 in the DevOps Runbook Series
- Trigger.dev runs as a separate Docker stack with its own PostgreSQL instance
- Every task requires maxDuration, concurrencyLimit, and retry configuration
- Idempotency keys on all external API calls (Stripe, SendGrid, etc.)
- Database access via service_role scoped to only required fields
- Trigger.dev logger instead of console.log (logs persist in dashboard)
Note on architecture: Trigger.dev consists of two parts: the Platform (webapp, dashboard, queue management) and the Worker (executes your tasks). We run both self-hosted on our own infrastructure. This runbook exclusively describes the self-hosted setup with Trigger.dev v3.
Series Table of Contents
This guide is part of our DevOps runbook series for self-hosted app stacks.
- Supabase Self-Hosting Runbook
- Running Next.js on Supabase Securely
- Deploying Supabase Edge Functions Securely
- Running Trigger.dev Background Jobs Securely - this article
- Claude Code as Security Control in DevOps Workflows
- Security Baseline for the Entire Stack
Article 1 covers the platform. Article 2 covers the app layer. Article 3 covers integrations. This article covers asynchronous job processing.
Architecture Overview
Browser
|
Next.js (App Layer)
|
+-- tasks.trigger("send-email", payload) <- start 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 -> external CRM API
|
+-- Supabase (via service_role or direct DB connection)
|
PostgreSQL
Ground rules:
User Request -> Next.js Server Action / Route Handler
Short Event -> Supabase Edge Function
Long-Running Task -> Trigger.dev Task
Periodic Task -> Trigger.dev Scheduled Task
Critical Security Point
Trigger.dev tasks typically have full database access without RLS. They connect either via the service_role key (through the Supabase client) or via a direct PostgreSQL connection (through DATABASE_URL). In both cases, Row Level Security policies do not apply. This is the most important difference from requests that go through PostgREST with the anon key.
Those familiar with the fundamentals of secrets management understand why this separation is essential.
Studies show that 35% of all background job failures are caused by missing idempotency on retries (Temporal.io Reliability Report 2024).
Task Configuration Guide
| Task Type | maxDuration | concurrencyLimit | Retry Strategy |
|---|---|---|---|
| Send email | 30s | 5-10 | 3x, exponential |
| Generate PDF | 120s | 2-3 | 3x, exponential |
| AI inference (LLM) | 300s | 1-3 | 2x, exponential |
| Database migration | 600s | 1 | 1x, no retry |
| Process webhook | 30s | 10 | 3x, exponential |
| CRM sync | 60s | 3-5 | 5x, exponential |
Part A - Architecture Decisions
A1 - Run Trigger.dev v3 Self-Hosted as a Separate Service
Implementation
Trigger.dev runs separately from Next.js and Supabase as its own Docker stack. The self-hosted v3 architecture consists of three components:
Next.js App (your Hetzner / OVH server)
|
+-- Trigger.dev Platform (Webapp + Queue + Dashboard)
|
+-- Trigger.dev Worker (executes your tasks)
|
+-- Trigger.dev PostgreSQL (dedicated DB instance)
# 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
Important: Trigger.dev needs its own PostgreSQL instance on port 5433. Do not share the Supabase database (port 5432). Trigger.dev stores queue status, run history, worker state, and metadata in its DB. This is different data from your application data.
Configuring the SDK in the Next.js project:
# 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:
In the self-hosted v3 setup, you deploy tasks via the CLI, which communicates with your local platform:
# Tasks deployen (zeigt auf eure self-hosted Platform)
npx trigger.dev@latest deploy --self-hosted
# Lokale Entwicklung
npx trigger.dev@latest dev
Note: The v3 Docker worker uses Docker-in-Docker (socket mounting). This means the Trigger.dev platform needs access to the Docker socket (
/var/run/docker.sock). This has security implications: a compromised Trigger.dev container could start arbitrary Docker containers. Therefore, Trigger.dev should ideally run on a dedicated server or in an isolated Docker network.
Verifiable Condition
# 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
If Trigger.dev runs in the same process as Next.js, long-running tasks block the web server. An AI task that takes 5 minutes occupies a Next.js worker. With only a few worker threads (default: 1 per CPU), the entire app becomes unresponsive for users. If Trigger.dev shares the Supabase database, job queue queries compete with user requests for database connections. If the Docker socket is mounted without network isolation, a compromised task can start containers that access the host network.
A2 - Control Database Access from Tasks
Implementation
Tasks need access to your application data in Supabase. There are two approaches:
Approach 1: Supabase client with service_role (recommended)
// 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!
)
}
Advantage: Goes through PostgREST, uses the Supabase API layer. Disadvantage: Bypasses RLS (service_role).
Approach 2: Direct DB connection (for complex 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 }
Disadvantage: Bypasses both RLS and PostgREST. Full DB access.
Rule: Regardless of the approach, the task has more privileges than a normal user request. Therefore, every task must clearly define its own scope.
// 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('*')
Those familiar with the RLS patterns from the Supabase Self-Hosting Runbook understand why the scope difference is critical.
Verifiable Condition
# 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
A task with direct DB access and unvalidated input can lead to SQL injection. A task that runs SELECT * on large tables can degrade database performance for all users. An uncontrolled connection pool (no max limit) can consume all available PostgreSQL connections and bring the entire application to a halt.
Part B - Implementation Checks
These rules apply to every task and must be verified with each deployment.
B1 - Define Tasks Correctly (v3 API)
Implementation
Trigger.dev v3 uses the task() function from @trigger.dev/sdk/v3. Each task is exported and has a unique 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 }
},
})
Triggering a Task from 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 }
}
Verifiable Condition
# 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
A task without export is not recognized by Trigger.dev and silently disappears during deployment without an error message. A task without a retry configuration uses the default (no retry), so a temporary API error (e.g., a SendGrid timeout) leads to permanent loss of the email.
B2 - Ensure Idempotency
Implementation
Trigger.dev has a built-in idempotency system via idempotencyKey. This is preferable to a self-built findUnique check.
When triggering (prevents duplicate triggering):
// 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
}
)
Inside the task (prevents duplicate side effects on 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 }
},
})
Verifiable Condition
# 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
Without idempotency protection when triggering, a duplicate webhook (Stripe retries on timeout) can trigger the same task twice. Without idempotency inside the task, a retry after a partial failure can cause double payments or duplicate emails.
B3 - Configure Timeouts and Concurrency Correctly
Implementation
Trigger.dev v3 supports maxDuration (runtime limit per task) and concurrencyLimit (parallel executions). Both must be set deliberately for each 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) => {
// ...
},
})
Reference values:
Email delivery: maxDuration 30s, concurrency 5-10
PDF generation: maxDuration 120s, concurrency 2-3
AI inference (LLM): maxDuration 300s, concurrency 1-3
Database migration: maxDuration 600s, concurrency 1
Webhook processing: maxDuration 30s, concurrency 10
Verifiable Condition
# 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
Without maxDuration, a task can run indefinitely on a hanging API call, permanently blocking a worker slot. Without concurrencyLimit, 100 simultaneously triggered email tasks can overwhelm the SMTP provider and lead to rate limiting. Without a concurrency limit on DB-intensive tasks, all PostgreSQL connections can be consumed at once.
B4 - Secrets and Environment Variables
Implementation
Trigger.dev tasks run in a separate environment. Secrets must be explicitly passed through the Docker Compose configuration.
# .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
Task secrets are passed to worker containers in the self-hosted setup through the Trigger.dev dashboard (environment variables) or through the Docker environment.
In code:
// RICHTIG: Environment Variable
const apiKey = process.env.SENDGRID_API_KEY
// FALSCH: Hardcoded
const apiKey = 'SG.xxx...'
Verifiable Condition
# 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 files end up in the Git repository and in the build artifact (worker image). In the self-hosted setup, worker images are built and stored locally. Hardcoded secrets in these images are visible to anyone with access to the Docker host or the registry.
B5 - Error Handling and Logging
Implementation
Tasks must handle errors cleanly and must not log sensitive data.
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,
})
},
})
What must NOT be logged:
// 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
Verifiable Condition
# 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 stores all logs and displays them in the dashboard. If console.log(user) logs a complete user object with email and metadata, that data is visible in the Trigger.dev dashboard to anyone with dashboard access, even months later, since run history is persistent.
Part C - Operations and Monitoring
C1 - Monitoring and Alerting
Implementation
Trigger.dev has a built-in dashboard with run history, logs, and traces. In addition, these metrics should be actively monitored:
Critical Metrics:
- Failed Runs (last 24h) -> Alert when > 5
- Queue Length -> Alert when > 100
- Average Run Duration -> Alert when > 2x baseline
- Runs in WAITING State -> Info, no immediate alert
Securing the self-hosted dashboard:
# 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
Automated 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
Verifiable Condition
# 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
Implementation
Tasks that fail after all retries end up in the FAILED status. These must be actively addressed.
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
Verifiable Condition
# 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
Architecture
Git Push / PR
|
+-- Deterministic Checks (CI/CD)
| +-- All tasks have maxDuration?
| +-- All tasks have concurrencyLimit?
| +-- External API calls have idempotency keys?
| +-- No hardcoded secrets?
| +-- No console.log (only logger)?
|
+-- Claude Code Analysis (weekly or on PR)
+-- New tasks without retry strategy?
+-- DB access correctly scoped?
+-- Idempotency patterns consistent?
+-- onFailure for critical tasks present?
+-- Concurrency limits appropriate?
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 does not execute automatic changes on production. The full audit setup with custom commands and cron-based reviews is described in Claude Code as a Security Control in DevOps Workflows.
Deployment Checklist
Before every deployment of Trigger.dev tasks, verify:
Architecture
[ ] Trigger.dev runs as a separate service (not in the Next.js process)
[ ] Dedicated PostgreSQL instance (not the Supabase DB)
[ ] Dashboard only accessible internally
Task Definition
[ ] Every task is exported
[ ] Every task has a unique ID
[ ] Every task has maxDuration
[ ] Every task has concurrencyLimit or a shared queue
[ ] Every task has retry configuration
Idempotency
[ ] Trigger calls have idempotencyKey where needed
[ ] External API calls inside tasks have idempotency
[ ] DB operations are idempotent (upsert instead of insert where possible)
Database Access
[ ] service_role only via createTaskClient()
[ ] Queries access only required data (no SELECT *)
[ ] Connection pool limited (max: 5-10)
Secrets
[ ] No hardcoded secrets in code
[ ] Secrets via environment variables / dashboard
[ ] .env.trigger not in Git
Logging
[ ] Trigger.dev logger instead of console.log
[ ] No sensitive data in logs (user objects, tokens, keys)
Error Handling
[ ] Critical tasks have onFailure hook
[ ] Failed jobs are stored in failed_jobs table
[ ] Alerting configured for permanent failures
Conclusion
Trigger.dev forms the background processing layer of the stack. Used correctly, it processes asynchronous jobs reliably with retries, idempotency, and monitoring.
The most critical security point is database access: tasks typically have more privileges than normal user requests because they work with service_role or a direct DB connection. Therefore, input validation, scope restriction, and idempotency must be deliberately implemented for every task.
The combination of built-in Trigger.dev retry logic, deliberate concurrency limits, and contextual Claude Code analysis leads to stable background workflows that function correctly even during partial failures.
Those who pursue these principles together with a Cert-Ready by Design architecture build verifiable security instead of retroactive audits.
Audit Checklist Download
Prepared prompt for Claude Code. Upload the file to your server and start Claude Code in your Trigger.dev setup's project directory. Claude Code will automatically check all security points from this runbook and report PASS, WARNING, or CRITICAL.
claude -p "$(cat claude-check-artikel-4-trigger-dev-en.md)" --allowedTools Read,Grep,Glob,Bash
Download checklistSeries Table of Contents
- Supabase Self-Hosting Runbook
- Running Next.js on Supabase Securely
- Deploying Supabase Edge Functions Securely
- Running Trigger.dev Background Jobs Securely - this article
- Claude Code as Security Control in DevOps Workflows
- Security Baseline for the Entire Stack
The next article describes how Claude Code is used as a security control in the DevOps workflow - as the overarching analysis layer across all previous articles.

Bert Gogolin
CEO & Founder, Gosign
AI Governance Briefing
Enterprise AI, regulation, and infrastructure - once a month, directly from me.