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.
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.
- Supabase Self-Hosting Runbook
- Next.js nad Supabase - bezpieczna eksploatacja
- Supabase Edge Functions - bezpieczne wdrożenie
- Trigger.dev Background Jobs - bezpieczna eksploatacja - ten artykuł
- Claude Code jako kontrola bezpieczeństwa w DevOps
- 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 taska | maxDuration | concurrencyLimit | Strategia Retry |
|---|---|---|---|
| Wysyłka e-mail | 30s | 5-10 | 3 próby, exponential backoff |
| Generowanie PDF | 120s | 2-3 | 2 próby, stały interwał |
| Inferencja AI (LLM) | 300s | 1-3 | 2 próby, exponential backoff |
| Migracja danych | 600s | 1 | 1 próba, manualna obsługa |
| Przetwarzanie webhooków | 30s | 10 | 3 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.
- Supabase Self-Hosting Runbook
- Next.js nad Supabase - bezpieczna eksploatacja
- Supabase Edge Functions - bezpieczne wdrożenie
- Trigger.dev Background Jobs - bezpieczna eksploatacja - ten artykuł
- Claude Code jako kontrola bezpieczeństwa w DevOps
- 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
Dyrektor Generalny, Gosign
AI Governance Briefing
Enterprise AI, regulacje i infrastruktura - raz w miesiącu, bezpośrednio ode mnie.