Przejdź do treści
Infrastruktura & Technologia

Trigger.dev Background Jobs - bezpieczna eksploatacja

Runbook DevOps dla Trigger.dev v3: setup self-hosted, definicja tasków, idempotencja, concurrency, sekrety i integracja z Claude Code.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 18 min czytania

Gdy aplikacja wykracza poza proste operacje CRUD, pojawiają się zadania, które nie powinny działać synchronicznie w cyklu request-response: wysyłka e-mail, przetwarzanie webhooków, zadania importu/eksportu, taski AI, generowanie PDF, migracja danych i zadania periodyczne.

Te zadania nie należą do Next.js Server Actions (blokują serwer webowy), nie należą do Supabase Edge Functions (limit timeout, brak obsługi zadań długotrwałych) ani do cron jobów na serwerze (brak logiki retry, brak monitoringu).

Należą do dedykowanej warstwy zadań. W naszym stacku tę rolę pełni Trigger.dev.

Ten runbook opisuje, jak bezpiecznie eksploatować Trigger.dev w stacku. Każdy krok zawiera konkretną implementację z aktualnym API Trigger.dev v3, sprawdzalny warunek i scenariusz awarii.

Uwaga dotycząca architektury: Trigger.dev składa się z dwóch części: Platform (webapp, dashboard, zarządzanie kolejką) i Worker (wykonuje taski). Obie części eksploatujemy self-hosted na własnej infrastrukturze. Ten runbook opisuje wyłącznie setup self-hosted z Trigger.dev v3.

W skrócie - Artykuł 4 z 6 serii DevOps Runbook

  • Trigger.dev jako osobny stack Docker z własnym PostgreSQL
  • Każdy task potrzebuje maxDuration, concurrencyLimit i konfiguracji retry
  • Klucze idempotencji przy zewnętrznych wywołaniach API
  • Dostęp do bazy danych ograniczony do potrzebnych pól
  • Logger Trigger.dev zamiast console.log

Spis treści serii

Ten przewodnik jest częścią naszej serii runbooków DevOps dla self-hosted stacków aplikacyjnych.

  1. Supabase Self-Hosting Runbook
  2. Next.js nad Supabase - bezpieczna eksploatacja
  3. Supabase Edge Functions - bezpieczne wdrożenie
  4. Trigger.dev Background Jobs - bezpieczna eksploatacja - ten artykuł
  5. Claude Code jako kontrola bezpieczeństwa w DevOps
  6. Security Baseline dla całego stacku

Artykuł 1 opisuje platformę. Artykuł 2 opisuje warstwę aplikacyjną. Artykuł 3 opisuje integracje. Ten artykuł opisuje asynchroniczne przetwarzanie zadań.

Przegląd architektury

Browser
   |
Next.js (warstwa aplikacyjna)
   |
   +-- tasks.trigger("send-email", payload)    <- uruchomienie zadania
   |
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             -> zewnętrzne CRM API
   |
   +-- Supabase (via service_role lub bezpośrednie połączenie DB)
       |
       PostgreSQL

Podstawowe zasady:

User Request       -> Next.js Server Action / Route Handler
Krótkie zdarzenie  -> Supabase Edge Function
Zadanie długotrwałe -> Trigger.dev Task
Periodyczne        -> Trigger.dev Scheduled Task

Przewodnik konfiguracji tasków

Typ taskamaxDurationconcurrencyLimitStrategia Retry
Wysyłka e-mail30s5-103 próby, exponential backoff
Generowanie PDF120s2-32 próby, stały interwał
Inferencja AI (LLM)300s1-32 próby, exponential backoff
Migracja danych600s11 próba, manualna obsługa
Przetwarzanie webhooków30s103 próby, exponential backoff

Krytyczny punkt bezpieczeństwa

Trigger.dev Tasks mają typowo pełny dostęp do bazy danych bez RLS. Łączą się albo przez klucz service_role (via Supabase Client), albo przez bezpośrednie połączenie PostgreSQL (via DATABASE_URL). W obu przypadkach Row Level Security Policies nie działają. To najważniejsza różnica w porównaniu z żądaniami przechodzącymi przez PostgREST z kluczem anon.

Kto zna podstawy zarządzania sekretami, rozumie dlaczego ta separacja jest niezbędna.

Część A - Decyzje architektoniczne

A1 - Trigger.dev v3 self-hosted jako osobna usługa

Implementacja

Trigger.dev działa oddzielnie od Next.js i Supabase jako osobny stack Docker. Architektura self-hosted v3 składa się z trzech komponentów:

Next.js App (Wasz serwer Hetzner / OVH)
   |
   +-- Trigger.dev Platform (webapp + kolejka + dashboard)
           |
           +-- Trigger.dev Worker (wykonuje wasze taski)
           |
           +-- Trigger.dev PostgreSQL (własna instancja DB)
# docker-compose.trigger.yml
services:
  trigger-platform:
    image: ghcr.io/triggerdotdev/trigger.dev:v3    # wersja przypięta
    restart: unless-stopped
    ports:
      - "127.0.0.1:3040:3040"        # TYLKO localhost, Reverse Proxy przed nim
    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
      # Konfiguracja Workera
      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, NIE 5432 (Supabase używa 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

Ważne: Trigger.dev potrzebuje własnej instancji PostgreSQL na porcie 5433. Nie współdziel bazy danych Supabase (port 5432). Trigger.dev przechowuje status kolejki, historię uruchomień, stan workerów i metadane w swojej bazie. To inne dane niż dane aplikacyjne.

Konfiguracja SDK w projekcie Next.js:

# Instalacja Trigger.dev SDK
npm install @trigger.dev/sdk@3

# .env (Next.js)
TRIGGER_SECRET_KEY=tr_dev_...              # z self-hosted dashboardu
TRIGGER_API_URL=http://localhost:3040      # lokalny URL platformy

Deployment Workera:

W setup self-hosted v3 taski deployujecie przez CLI, które komunikuje się z lokalną platformą:

# Deploy tasków (wskazuje na waszą self-hosted platformę)
npx trigger.dev@latest deploy --self-hosted

# Lokalne środowisko deweloperskie
npx trigger.dev@latest dev

Uwaga: Worker v3 Docker korzysta z Docker-in-Docker (montowanie socketu). Oznacza to, że platforma Trigger.dev potrzebuje dostępu do Docker Socket (/var/run/docker.sock). Ma to implikacje bezpieczeństwa: skompromitowany kontener Trigger.dev mógłby uruchamiać dowolne kontenery Docker. Dlatego Trigger.dev powinien działać na osobnym serwerze lub w izolowanej sieci Docker.

Sprawdzalny warunek

# Kontenery działają?
docker compose -f docker-compose.trigger.yml ps
# Oczekiwanie: trigger-platform i trigger-db running

# Platforma dostępna (wewnętrznie)?
curl -s -o /dev/null -w "%{http_code}" http://localhost:3040
# Oczekiwanie: 200 lub 302 (redirect do logowania)

# Platforma NIE jest dostępna bezpośrednio z zewnątrz?
curl -s -o /dev/null -w "%{http_code}" https://trigger.example.com:3040
# Oczekiwanie: Connection refused (tylko przez Reverse Proxy)

# Trigger.dev DB na własnym porcie?
ss -tlnp | grep 5433
# Oczekiwanie: Listening na 127.0.0.1:5433

# Supabase DB na innym porcie?
ss -tlnp | grep 5432
# Oczekiwanie: Listening na 10.0.1.10:5432 (wewnętrzny interfejs)

# Docker Socket zamontowany?
docker compose -f docker-compose.trigger.yml exec trigger-platform ls -la /var/run/docker.sock
# Oczekiwanie: Socket obecny

Scenariusz awarii

Jeśli Trigger.dev działa w tym samym procesie co Next.js, długotrwałe taski blokują serwer webowy. Task AI trwający 5 minut zajmuje wątek workera Next.js. Przy niewielkiej liczbie wątków workerów (domyślnie: 1 na CPU) cała aplikacja staje się nieresponsywna dla użytkowników. Jeśli Trigger.dev współdzieli bazę Supabase, zapytania kolejki zadań konkurują z żądaniami użytkowników o połączenia z bazą danych. Jeśli Docker Socket jest zamontowany bez izolacji sieci, skompromitowany task może uruchamiać kontenery z dostępem do sieci hosta.

A2 - Kontrolowanie dostępu do bazy danych z tasków

Implementacja

Taski potrzebują dostępu do danych aplikacyjnych w Supabase. Są dwa sposoby:

Sposób 1: Supabase Client z service_role (zalecany)

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

Zaleta: Przechodzi przez PostgREST, korzysta z warstwy API Supabase. Wada: Omija RLS (service_role).

Sposób 2: Bezpośrednie połączenie DB (dla złożonych zapytań)

// trigger/lib/db.ts
import postgres from 'postgres'

const sql = postgres(process.env.DATABASE_URL!, {
  max: 5,                    // Limit puli połączeń
  idle_timeout: 20,
  connect_timeout: 10,
})

export { sql }

Wada: Omija zarówno RLS, jak i PostgREST. Pełny dostęp do bazy danych.

Zasada: Niezależnie od wybranego sposobu, task ma więcej uprawnień niż normalne żądanie użytkownika. Dlatego każdy task musi jasno definiować swój własny zakres.

// POPRAWNIE: Task pobiera tylko to, czego potrzebuje
const { data } = await supabase
  .from('invoices')
  .select('id, amount, user_id')
  .eq('id', payload.invoiceId)
  .single()

// ŹŁLE: Task odczytuje wszystkie dane z tabeli
const { data } = await supabase
  .from('invoices')
  .select('*')

Kto zna wzorce RLS z runbooka Supabase Self-Hosting, rozumie dlaczego różnica w zakresie jest krytyczna.

Sprawdzalny warunek

# Które taski używają service_role lub DATABASE_URL?
grep -rn "SERVICE_ROLE\|DATABASE_URL\|createTaskClient\|postgres(" \
  trigger/ --include="*.ts"

# Dla każdego wyniku sprawdź ręcznie:
# - Czy task pobiera tylko dane, których potrzebuje?
# - Czy dane wejściowe są walidowane przed użyciem w zapytaniu?
# - Czy jest uzasadnienie dla bezpośredniego dostępu do DB zamiast Supabase Client?

Scenariusz awarii

Task z bezpośrednim dostępem do bazy danych i niewalidowanymi danymi wejściowymi może prowadzić do SQL Injection. Task wykonujący SELECT * na dużych tabelach może obniżyć wydajność bazy danych dla wszystkich użytkowników. Niekontrolowana pula połączeń (brak limitu max) może zająć wszystkie dostępne połączenia PostgreSQL i sparaliżować całą aplikację.

Część B - Kontrole implementacyjne

Te zasady obowiązują dla każdego taska i muszą być weryfikowane przy każdym deploymencie.

B1 - Poprawna definicja tasków (API v3)

Implementacja

Trigger.dev v3 używa funkcji task() z @trigger.dev/sdk/v3. Każdy task jest eksportowany i ma unikalne ID.

// trigger/tasks/send-welcome-email.ts
import { task } from '@trigger.dev/sdk/v3'
import { z } from 'zod'
import { createTaskClient } from '../lib/supabase'

// 1. Definicja schematu payloadu
const payloadSchema = z.object({
  userId: z.string().uuid(),
  email: z.string().email(),
})

export const sendWelcomeEmail = task({
  id: 'send-welcome-email',

  // 2. Strategia retry
  retry: {
    maxAttempts: 3,
    factor: 2,
    minTimeoutInMs: 1_000,
    maxTimeoutInMs: 30_000,
  },

  // 3. Ograniczenie concurrency
  queue: {
    concurrencyLimit: 5,
  },

  // 4. Maksymalny czas wykonania
  maxDuration: 60,   // 60 sekund

  // 5. Funkcja run
  run: async (payload) => {
    // Walidacja danych wejściowych
    const parsed = payloadSchema.safeParse(payload)
    if (!parsed.success) {
      throw new Error(`Invalid payload: ${parsed.error.message}`)
    }

    const { userId, email } = parsed.data
    const supabase = createTaskClient()

    // Pobranie danych użytkownika (tylko niezbędnych)
    const { data: user } = await supabase
      .from('profiles')
      .select('full_name, locale')
      .eq('id', userId)
      .single()

    if (!user) {
      throw new Error(`User not found: ${userId}`)
    }

    // Wysłanie e-maila
    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) {
      // Rzucenie błędu, aby zadziałał retry
      throw new Error(`SendGrid error: ${response.status}`)
    }

    // Aktualizacja statusu w bazie danych
    await supabase
      .from('profiles')
      .update({ welcome_email_sent: true })
      .eq('id', userId)

    return { success: true, userId }
  },
})

Triggerowanie taska z 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) {
  // Triggerowanie taska (wraca natychmiast)
  const handle = await tasks.trigger<typeof sendWelcomeEmail>(
    'send-welcome-email',
    { userId, email }
  )

  // handle.id zawiera Run-ID do śledzenia
  return { triggered: true, runId: handle.id }
}

Sprawdzalny warunek

# Wszystkie taski muszą być wyeksportowane
for file in trigger/tasks/*.ts; do
  if ! grep -q "export const" "$file"; then
    echo "OSTRZEŻENIE: $file nie ma wyeksportowanego taska"
  fi
done

# Wszystkie taski muszą mieć ID
grep -rn "id:" trigger/tasks/ --include="*.ts" | grep "task({" -A1

# Taski NIE MOGĄ być triggerowane z kodu klienta
grep -rn "tasks.trigger\|\.trigger(" app/ --include="*.tsx" | grep -v "use server"
# Oczekiwanie: brak wyników (tylko w kontekście serwerowym)

Scenariusz awarii

Task bez export nie zostanie rozpoznany przez Trigger.dev i zniknie podczas deploymentu bez komunikatu o błędzie. Task bez konfiguracji retry używa wartości domyślnej (brak retry), więc tymczasowy błąd API (np. timeout SendGrid) prowadzi do trwałej utraty e-maila.

B2 - Zapewnienie idempotencji

Implementacja

Trigger.dev ma wbudowany system idempotencji oparty na idempotencyKey. Jest to lepsze rozwiązanie niż własnoręcznie napisane sprawdzanie findUnique.

Przy triggerowaniu (zapobiega podwójnemu uruchomieniu):

// Gdy ten sam użytkownik dwukrotnie szybko kliknie "Signup",
// task zostanie wykonany tylko raz
await tasks.trigger<typeof sendWelcomeEmail>(
  'send-welcome-email',
  { userId, email },
  {
    idempotencyKey: `welcome-email-${userId}`,
    idempotencyKeyTTL: '24h',     // Klucz ważny 24 godziny
  }
)

Wewnątrz taska (zapobiega podwójnym efektom ubocznym przy retry):

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 dla wywołania Stripe
    // Przy retry wywołanie Stripe NIE zostanie powtórzone
    const stripeKey = await idempotencyKeys.create(
      `stripe-charge-${payload.orderId}`
    )

    const charge = await stripe.charges.create(
      {
        amount: payload.amount,
        currency: 'eur',
        source: 'tok_...',
      },
      { idempotencyKey: stripeKey }
    )

    // Aktualizacja DB jest idempotentna (upsert zamiast insert)
    await supabase
      .from('payments')
      .upsert({
        order_id: payload.orderId,
        stripe_charge_id: charge.id,
        status: 'completed',
      }, { onConflict: 'order_id' })

    return { chargeId: charge.id }
  },
})

Sprawdzalny warunek

# Taski z zewnętrznymi wywołaniami API powinny mieć Idempotency Keys
for file in trigger/tasks/*.ts; do
  if grep -qE "fetch\(|stripe\.|sendgrid\.|resend\." "$file"; then
    if ! grep -qE "idempotencyKey|idempotencyKeys" "$file"; then
      echo "OSTRZEŻENIE: $file ma zewnętrzne wywołania API bez Idempotency Key"
    fi
  fi
done

Scenariusz awarii

Bez ochrony idempotencji przy triggerowaniu podwójny webhook (Stripe ponawia przy timeout) może uruchomić ten sam task dwukrotnie. Bez idempotencji wewnątrz taska retry po częściowym błędzie może spowodować podwójne płatności lub podwójne wysłanie e-maili.

B3 - Poprawna konfiguracja timeoutów i concurrency

Implementacja

Trigger.dev v3 oferuje maxDuration (limit czasu wykonania na task) i concurrencyLimit (równoległe wykonania). Oba muszą być ustawiane świadomie dla każdego taska.

import { task, queue } from '@trigger.dev/sdk/v3'

// Współdzielona kolejka dla tasków e-mailowych (zapobiega SMTP Rate Limiting)
const emailQueue = queue({
  name: 'email-processing',
  concurrencyLimit: 5,       // maks. 5 równoległych tasków e-mailowych
})

export const sendEmail = task({
  id: 'send-email',
  queue: emailQueue,
  maxDuration: 30,            // maks. 30 sekund

  run: async (payload) => {
    // ...
  },
})

// Taski AI potrzebują więcej czasu, ale mniej równoległości
export const analyzeDocument = task({
  id: 'analyze-document',
  queue: { concurrencyLimit: 2 },   // maks. 2 równoległe wywołania AI
  maxDuration: 300,                  // maks. 5 minut

  run: async (payload) => {
    // ...
  },
})

// Krytyczne taski wymagające sekwencyjnego wykonania
export const processInvoice = task({
  id: 'process-invoice',
  queue: { concurrencyLimit: 1 },    // ściśle sekwencyjnie
  maxDuration: 60,

  run: async (payload) => {
    // ...
  },
})

Wartości orientacyjne:

Wysyłka e-maila:          maxDuration 30s,    concurrency 5-10
Generowanie PDF:          maxDuration 120s,   concurrency 2-3
Inferecja AI (LLM):       maxDuration 300s,   concurrency 1-3
Migracja bazy danych:     maxDuration 600s,   concurrency 1
Przetwarzanie webhooków:  maxDuration 30s,    concurrency 10

Sprawdzalny warunek

# Wszystkie taski muszą mieć maxDuration
for file in trigger/tasks/*.ts; do
  if ! grep -q "maxDuration" "$file"; then
    echo "OSTRZEŻENIE: $file nie ma maxDuration"
  fi
done

# Wszystkie taski muszą mieć concurrencyLimit (lub używać współdzielonej kolejki)
for file in trigger/tasks/*.ts; do
  if ! grep -qE "concurrencyLimit|queue:" "$file"; then
    echo "OSTRZEŻENIE: $file nie ma limitu concurrency"
  fi
done

Scenariusz awarii

Bez maxDuration task z zawieszonym wywołaniem API może działać w nieskończoność i trwale blokować slot workera. Bez concurrencyLimit 100 jednocześnie wyzwolonych tasków e-mailowych może przeciążyć dostawcę SMTP i doprowadzić do Rate Limiting. Bez limitu concurrency na taskach intensywnie korzystających z bazy danych wszystkie połączenia PostgreSQL mogą zostać zajęte jednocześnie.

Statystyka: Zgodnie z danymi Trigger.dev ponad 40% samodzielnie hostowanych instancji produkcyjnych ma taski bez skonfigurowanego maxDuration - co prowadzi do cichego blokowania workerów.

B4 - Sekrety i zmienne środowiskowe

Implementacja

Taski Trigger.dev działają w osobnym środowisku. Sekrety muszą być przekazywane jawnie przez konfigurację Docker Compose.

# .env.trigger (tylko na serwerze, nie w Git)
TRIGGER_DB_PASSWORD=...
MAGIC_LINK_SECRET=...
SESSION_SECRET=...
ENCRYPTION_KEY=...

# Sekrety dla waszych tasków (przekazywane do Workera)
SUPABASE_SERVICE_ROLE_KEY=eyJ...
DATABASE_URL=postgresql://...
SENDGRID_API_KEY=SG.xxx
OPENAI_API_KEY=sk-xxx
STRIPE_SECRET_KEY=sk_live_xxx

Sekrety tasków w setup self-hosted przekazywane są do kontenerów workerów przez dashboard Trigger.dev (Environment Variables) lub przez środowisko Docker.

W kodzie:

// POPRAWNIE: zmienna środowiskowa
const apiKey = process.env.SENDGRID_API_KEY

// ŹŁLE: na stałe w kodzie
const apiKey = 'SG.xxx...'

Sprawdzalny warunek

# Sekrety na stałe w kodzie?
grep -rn "sk_live\|sk_test\|SG\.\|sk-\|Bearer ey" \
  trigger/ --include="*.ts"
# Oczekiwanie: brak wyników

# .env.trigger nie jest w Git?
git ls-files .env.trigger
# Oczekiwanie: pusty wynik

# Wszystkie wymagane zmienne środowiskowe ustawione?
for var in SUPABASE_SERVICE_ROLE_KEY SENDGRID_API_KEY; do
  if [ -z "${!var}" ]; then
    echo "BRAK: $var"
  fi
done

Scenariusz awarii

Sekrety w plikach tasków trafiają do repozytorium Git i do artefaktu build (obraz Worker). W setup self-hosted obrazy workerów budowane są i przechowywane lokalnie. Sekrety zakodowane na stałe w tych obrazach są widoczne dla każdego z dostępem do hosta Docker lub rejestru.

B5 - Obsługa błędów i logowanie

Implementacja

Taski muszą poprawnie obsługiwać błędy i nie logować wrażliwych danych.

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 }) => {
    // Logger Trigger.dev (strukturalny, widoczny w dashboardzie)
    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) {
      // Strukturalne logowanie (bez wrażliwych szczegółów)
      logger.error('Order processing failed', {
        orderId: payload.orderId,
        errorMessage: error instanceof Error ? error.message : 'Unknown error',
        // NIE: error.stack, szczegóły payloadu, dane użytkownika
      })

      // Przekazanie błędu, aby zadziałał retry
      throw error
    }
  },

  // Hook onFailure: wywoływany po wyczerpaniu wszystkich retry
  onFailure: async (payload, error, params) => {
    logger.error('Order permanently failed after all retries', {
      orderId: payload.orderId,
      attemptNumber: params.run.attemptNumber,
    })

    // Wysłanie alertu (np. powiadomienie Slack)
    await sendAlertToOps({
      task: 'process-order',
      orderId: payload.orderId,
      error: error.message,
    })
  },
})

Czego NIE wolno logować:

// ŹŁLE
logger.info('Processing user', { user })             // pełny obiekt użytkownika
logger.info('API call', { headers: req.headers })     // nagłówek Auth z tokenem
logger.info('DB query', { connectionString })         // URL bazy danych
console.log(process.env.SUPABASE_SERVICE_ROLE_KEY)    // sekret

Sprawdzalny warunek

# console.log zamiast loggera?
grep -rn "console\.log\|console\.error" trigger/tasks/ --include="*.ts"
# Oczekiwanie: brak wyników (zawsze używaj loggera z @trigger.dev/sdk)

# Wrażliwe dane w logach?
grep -rn "logger\.\(info\|error\|warn\)" trigger/ --include="*.ts" | \
  grep -iE "password|secret|key|token|email.*:" | \
  grep -v "orderId\|taskId\|runId"
# Oczekiwanie: brak wyników

Scenariusz awarii

Trigger.dev przechowuje wszystkie logi i wyświetla je w dashboardzie. Jeśli console.log(user) zaloguje pełny obiekt użytkownika z adresem e-mail i metadanymi, dane te będą widoczne w dashboardzie Trigger.dev dla każdego z dostępem do dashboardu, nawet miesiące później, ponieważ historia uruchomień jest przechowywana na stałe.

Część C - Eksploatacja i monitoring

C1 - Monitoring i alerting

Implementacja

Trigger.dev ma wbudowany dashboard z historią uruchomień, logami i traces. Dodatkowo te metryki powinny być aktywnie monitorowane:

Krytyczne metryki:
  - Failed Runs (ostatnie 24h)         -> Alert gdy > 5
  - Queue Length                       -> Alert gdy > 100
  - Average Run Duration               -> Alert gdy > 2x Baseline
  - Runs w stanie WAITING              -> Info, brak natychmiastowego alertu

Zabezpieczenie dashboardu self-hosted:

# Dashboard Trigger.dev NIE MOŻE być publicznie dostępny
# Dostęp tylko przez:
# - Reverse Proxy z Basic Auth
# - VPN / WireGuard
# - SSH Tunnel

# Przykład: SSH Tunnel do dashboardu
ssh -L 3040:localhost:3040 deploy@trigger-server

# Następnie w przeglądarce: http://localhost:3040

Zautomatyzowany Health Check:

#!/bin/bash
# scripts/check-trigger-health.sh

# Platforma Trigger.dev dostępna?
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3040/healthcheck)
if [ "$HEALTH" != "200" ]; then
  echo "KRYTYCZNE: Platforma Trigger.dev niedostępna"
fi

# Kontener Workera działa?
WORKER_STATUS=$(docker compose -f docker-compose.trigger.yml ps trigger-worker --format '{{.State}}')
if [ "$WORKER_STATUS" != "running" ]; then
  echo "KRYTYCZNE: Worker Trigger.dev nie działa ($WORKER_STATUS)"
fi

# Failed Runs z ostatnich 24h (przez bazę Trigger.dev)
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 "OSTRZEŻENIE: $FAILED nieudanych uruchomień w ciągu ostatnich 24h"
fi

Sprawdzalny warunek

# Dashboard dostępny tylko wewnętrznie?
curl -s -o /dev/null -w "%{http_code}" https://trigger.example.com:3040
# Oczekiwanie: Connection refused lub 401

# Skrypt Health Check uruchamiany codziennie?
crontab -l | grep "check-trigger-health"
# Oczekiwanie: wpis obecny

C2 - Obsługa Dead Letter

Implementacja

Taski, które zawiodą po wyczerpaniu wszystkich retry, trafiają do statusu FAILED. Muszą być aktywnie obsługiwane.

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

  onFailure: async (payload, error, params) => {
    const supabase = createTaskClient()

    // Zapis nieudanego zadania do osobnej tabeli
    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,
    })
  },
})
-- Migracja: tabela failed_jobs
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: dostęp tylko dla service_role
ALTER TABLE failed_jobs ENABLE ROW LEVEL SECURITY;
-- Brak Policy dla anon/authenticated = brak dostępu przez PostgREST

Sprawdzalny warunek

# Czy istnieją nierozwiązane Failed Jobs?
# Przez Supabase Client lub bezpośrednie zapytanie DB
psql -c "SELECT count(*) FROM failed_jobs WHERE resolved = false;"
# Oczekiwanie: 0 (lub aktywnie w trakcie obsługi)

# Czy wszystkie krytyczne taski mają hook onFailure?
grep -L "onFailure" trigger/tasks/*.ts
# Oczekiwanie: tylko niekrytyczne taski bez onFailure

C3 - Integracja z Claude Code

Architektura

Git Push / PR
   |
   +-- Kontrole deterministyczne (CI/CD)
   |   +-- Wszystkie taski mają maxDuration?
   |   +-- Wszystkie taski mają concurrencyLimit?
   |   +-- Zewnętrzne wywołania API mają Idempotency Keys?
   |   +-- Brak hardcoded secrets?
   |   +-- Brak console.log (tylko logger)?
   |
   +-- Analiza Claude Code (cotygodniowo lub przy PR)
       +-- Nowe taski bez strategii retry?
       +-- Dostęp do DB poprawnie ograniczony?
       +-- Wzorce idempotencji spójne?
       +-- onFailure dla krytycznych tasków obecny?
       +-- Limity concurrency odpowiednie?

Skrypt CI

#!/bin/bash
# scripts/check-trigger-tasks.sh

REPORT=""

# 1. Taski bez maxDuration
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if ! grep -q "maxDuration" "$file"; then
    REPORT+="OSTRZEŻENIE: $name nie ma maxDuration\n"
  fi
done

# 2. Taski bez Concurrency
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if ! grep -qE "concurrencyLimit|queue:" "$file"; then
    REPORT+="OSTRZEŻENIE: $name nie ma limitu concurrency\n"
  fi
done

# 3. Zewnętrzne wywołania API bez 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+="OSTRZEŻENIE: $name ma zewnętrzne wywołania API bez 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+="KRYTYCZNE: Hardcoded Secrets:\n$SECRETS\n\n"
fi

# 5. console.log zamiast loggera
CONSOLE=$(grep -rn "console\.\(log\|error\|warn\)" trigger/tasks/ --include="*.ts" 2>/dev/null)
if [ -n "$CONSOLE" ]; then
  REPORT+="OSTRZEŻENIE: console.log zamiast loggera Trigger.dev:\n$CONSOLE\n\n"
fi

# 6. Niewyeksportowane taski
for file in trigger/tasks/*.ts; do
  name=$(basename "$file" .ts)
  if ! grep -q "export const" "$file"; then
    REPORT+="KRYTYCZNE: Task $name nie jest wyeksportowany\n"
  fi
done

if [ -n "$REPORT" ]; then
  echo -e "=== Trigger.dev Task Security Check ===\n$REPORT"
else
  echo "Wszystkie kontrole Trigger.dev przeszły pomyślnie."
fi

Claude nie wykonuje żadnych automatycznych zmian na produkcji.

Lista kontrolna deploymentu

Przed każdym deploymentem tasków Trigger.dev sprawdź:

Architektura
  [ ] Trigger.dev działa jako osobna usługa (nie w procesie Next.js)
  [ ] Własna instancja PostgreSQL (nie baza Supabase)
  [ ] Dashboard dostępny tylko wewnętrznie

Definicja tasków
  [ ] Każdy task jest wyeksportowany
  [ ] Każdy task ma unikalne ID
  [ ] Każdy task ma maxDuration
  [ ] Każdy task ma concurrencyLimit lub współdzieloną kolejkę
  [ ] Każdy task ma konfigurację retry

Idempotencja
  [ ] Wywołania trigger mają idempotencyKey tam, gdzie to konieczne
  [ ] Zewnętrzne wywołania API wewnątrz tasków mają Idempotency
  [ ] Operacje bazodanowe są idempotentne (upsert zamiast insert, gdzie to możliwe)

Dostęp do bazy danych
  [ ] service_role tylko przez createTaskClient()
  [ ] Zapytania pobierają tylko potrzebne dane (brak SELECT *)
  [ ] Pula połączeń ograniczona (max: 5-10)

Sekrety
  [ ] Brak hardcoded secrets w kodzie
  [ ] Sekrety przez zmienne środowiskowe / dashboard
  [ ] .env.trigger nie jest w Git

Logowanie
  [ ] Logger Trigger.dev zamiast console.log
  [ ] Brak wrażliwych danych w logach (obiekty użytkowników, tokeny, klucze)

Obsługa błędów
  [ ] Krytyczne taski mają hook onFailure
  [ ] Failed Jobs zapisywane w tabeli failed_jobs
  [ ] Alerting przy trwałych błędach skonfigurowany

Podsumowanie

Trigger.dev stanowi warstwę przetwarzania zadań w tle w stacku. Prawidłowo wdrożony przetwarza asynchroniczne zadania niezawodnie, z retry, idempotencją i monitoringiem.

Najkrytyczniejszym punktem bezpieczeństwa jest dostęp do bazy danych: taski mają typowo więcej uprawnień niż normalne żądania użytkowników, ponieważ działają z service_role lub bezpośrednim połączeniem DB. Dlatego walidacja danych wejściowych, ograniczenie zakresu i idempotencja muszą być świadomie implementowane przy każdym tasku.

Połączenie wbudowanej logiki retry Trigger.dev, świadomych limitów concurrency i kontekstowej analizy Claude Code prowadzi do stabilnych workflow przetwarzania w tle, które działają poprawnie nawet przy częściowych awariach.

Kto stosuje te zasady razem z architekturą Cert-Ready by Design, buduje weryfikowalne bezpieczeństwo zamiast audytów po fakcie.

Pobierz listę kontrolną audytu

Przygotowany prompt dla Claude Code. Prześlij plik na swój serwer i uruchom Claude Code w katalogu projektu konfiguracji Trigger.dev. Claude Code automatycznie sprawdzi wszystkie punkty bezpieczeństwa z tego runbooka i zgłosi ZALICZONY, OSTRZEŻENIE lub KRYTYCZNY.

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

Pobierz listę kontrolną

Spis treści serii

Ten artykuł jest częścią naszej serii DevOps dla self-hosted stacków aplikacyjnych.

  1. Supabase Self-Hosting Runbook
  2. Next.js nad Supabase - bezpieczna eksploatacja
  3. Supabase Edge Functions - bezpieczne wdrożenie
  4. Trigger.dev Background Jobs - bezpieczna eksploatacja - ten artykuł
  5. Claude Code jako kontrola bezpieczeństwa w DevOps
  6. Security Baseline dla całego stacku

W kolejnym artykule pokażemy, jak Claude Code jest wykorzystywany jako kontrola bezpieczeństwa w workflow DevOps - jako nadrzędna warstwa analityczna obejmująca wszystkie dotychczasowe artykuły.

Bert Gogolin

Bert Gogolin

Dyrektor Generalny, Gosign

AI Governance Briefing

Enterprise AI, regulacje i infrastruktura - raz w miesiącu, bezpośrednio ode mnie.

Bez spamu. Możliwość rezygnacji w każdej chwili. Polityka prywatności

Trigger.dev Background Jobs DevOps Self-Hosting Security
Udostępnij artykuł

Najczęściej zadawane pytania

Dlaczego Background Jobs nie powinny działać w Next.js Server Actions?

Server Actions blokują serwer webowy Next.js. Task AI trwający 5 minut zajmuje wątek workera. Przy niewielkiej liczbie wątków workerów cała aplikacja staje się nieresponsywna dla użytkowników.

Dlaczego Trigger.dev potrzebuje własnej instancji PostgreSQL?

Trigger.dev przechowuje status kolejki, historię uruchomień i stan workerów w swojej bazie danych. To inne dane niż dane aplikacyjne w PostgreSQL Supabase. Współdzielenie bazy prowadzi do konfliktów połączeń.

Co się stanie przy retry bez klucza idempotencji?

Jeśli task zostanie ponownie wykonany po częściowym błędzie i nie ma klucza idempotencji, zewnętrzne wywołania API zostaną powtórzone. Może to prowadzić do podwójnych płatności w Stripe, podwójnych e-maili lub zduplikowanych wpisów w bazie danych. Wbudowany system idempotencyKeys Trigger.dev temu zapobiega.