Skip to content
Infrastructure & Technology

Running Trigger.dev Background Jobs Securely

DevOps runbook for Trigger.dev v3: self-hosted setup, task definition, idempotency, concurrency, secrets, and Claude Code integration.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 18 min read

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.

  1. Supabase Self-Hosting Runbook
  2. Running Next.js on Supabase Securely
  3. Deploying Supabase Edge Functions Securely
  4. Running Trigger.dev Background Jobs Securely - this article
  5. Claude Code as Security Control in DevOps Workflows
  6. 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 TypemaxDurationconcurrencyLimitRetry Strategy
Send email30s5-103x, exponential
Generate PDF120s2-33x, exponential
AI inference (LLM)300s1-32x, exponential
Database migration600s11x, no retry
Process webhook30s103x, exponential
CRM sync60s3-55x, 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 checklist

Series Table of Contents

  1. Supabase Self-Hosting Runbook
  2. Running Next.js on Supabase Securely
  3. Deploying Supabase Edge Functions Securely
  4. Running Trigger.dev Background Jobs Securely - this article
  5. Claude Code as Security Control in DevOps Workflows
  6. 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

Bert Gogolin

CEO & Founder, Gosign

AI Governance Briefing

Enterprise AI, regulation, and infrastructure - once a month, directly from me.

No spam. Unsubscribe anytime. Privacy policy

Trigger.dev Background Jobs DevOps Self-Hosting Security
Share this article

Frequently Asked Questions

Why should background jobs not run in Next.js Server Actions?

Server Actions block the Next.js web server. An AI task that takes 5 minutes occupies a worker thread. With only a few worker threads, the entire app becomes unresponsive for users.

Why does Trigger.dev need its own PostgreSQL instance?

Trigger.dev stores queue status, run history, and worker state in its database. This is different data from the application data in the Supabase PostgreSQL instance. Sharing a single database leads to conflicts with database connections.

What happens on a retry without an idempotency key?

If a task is re-executed after a partial failure and has no idempotency key, external API calls are repeated. This can lead to duplicate payments at Stripe, duplicate emails, or duplicate database entries. Trigger.dev's built-in idempotencyKeys system prevents this.