Node.js AI Workflow with BullMQ: Reliable Tutorial A developer at Gate of AI published a tutorial on building a reliable Node.js AI workflow using BullMQ, Redis, PostgreSQL, and OpenAI. The system accepts authenticated webhooks, stores work items in PostgreSQL, processes them asynchronously via BullMQ, and validates AI responses with Zod. The tutorial emphasizes keeping PostgreSQL as the system of record and using LLMs only for bounded interpretation tasks. 🚀 Technical Briefing:This tutorial is part of our deep-dive series on Agentic Workflows at Gate of AI . For the full technical breakdown, interactive code sandbox, and the native Arabic translation, visit the original article here . Build a dependable Node.js AI workflow that accepts authenticated webhooks, stores work safely in PostgreSQL, processes jobs with BullMQ and Redis, calls OpenAI asynchronously, and returns a validated result. This tutorial builds a small, production-minded work-intake service. Another system sends a work item to POST /webhooks/work-items . The API validates the payload, stores it in PostgreSQL, adds a BullMQ job, and returns 202 Accepted without waiting for an AI response. A separate worker receives the job from Redis, loads the canonical record from PostgreSQL, asks OpenAI to classify the item, validates the returned JSON with Zod, and saves the outcome. The API exposes GET /work-items/:id for polling and GET /ready for dependency checks. This separation is important. An LLM can assist with bounded interpretation such as classification and summarisation, but it should not become the system of record or the policy engine. PostgreSQL owns business state. Redis and BullMQ coordinate background execution. Application code enforces deterministic handling for security-sensitive categories. The pattern is also relevant for GCC organisations that receive support, engineering, compliance, or operational requests across multiple systems. Before deploying, assess the data-residency, retention, Arabic-language evaluation, access-control, and regional hosting requirements that apply to your organisation. The verified workflow context supports the general architecture: AI orchestration systems use Redis-backed queues, background workers, APIs, task state, and external ticket providers. This tutorial deliberately keeps the stack self-managed and code-first. Teams that prefer managed TypeScript workflow infrastructure can evaluate that option separately, but the reliability boundaries described here still apply. mkdir node-ai-workflow cd node-ai-workflow npm init -y npm install bullmq dotenv express ioredis openai pg pino pino-http zod npm install -D @types/express @types/node @types/pg tsx typescript mkdir -p src db Replace package.json with scripts for independent API and worker processes. { "name": "node-ai-workflow", "private": true, "type": "module", "scripts": { "dev:api": "tsx watch src/api.ts", "dev:worker": "tsx watch src/worker.ts", "start:api": "tsx src/api.ts", "start:worker": "tsx src/worker.ts" } } { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist" }, "include": "src/ / .ts" } Create local PostgreSQL and Redis services. PostgreSQL is persistent workflow storage; Redis is the BullMQ processing dependency. cat docker-compose.yml <<'EOF' services: postgres: image: postgres:alpine environment: POSTGRES DB: ai workflow POSTGRES USER: workflow user POSTGRES PASSWORD: workflow password ports: - "5432:5432" volumes: - postgres data:/var/lib/postgresql/data redis: image: redis:alpine command: "redis-server", "--appendonly", "yes" ports: - "6379:6379" volumes: - redis data:/data volumes: postgres data: redis data: EOF docker compose up -d cat .env <<'EOF' PORT=3000 LOG LEVEL=info DATABASE URL=postgresql://workflow user:workflow password@localhost:5432/ai workflow REDIS URL=redis://localhost:6379 OPENAI API KEY=replace-with-your-key OPENAI MODEL=replace-with-a-model-available-to-your-account WEBHOOK SHARED SECRET=local-development-secret-change-before-production EOF cat .gitignore <<'EOF' node modules dist .env .log EOF The unique idempotency key is essential. Webhook senders can retry a delivery after a timeout or network failure. A unique database constraint turns duplicate delivery into a repeatable lookup instead of a second workflow run. cat db/001 create work items.sql <<'EOF' CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE work items id UUID PRIMARY KEY DEFAULT gen random uuid , idempotency key TEXT NOT NULL UNIQUE, source TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'queued' CHECK status IN 'queued', 'processing', 'completed', 'failed' , attempt count INTEGER NOT NULL DEFAULT 0, ai result JSONB, failure reason TEXT, created at TIMESTAMPTZ NOT NULL DEFAULT now , updated at TIMESTAMPTZ NOT NULL DEFAULT now , completed at TIMESTAMPTZ ; CREATE INDEX work items status created at idx ON work items status, created at DESC ; EOF docker compose exec -T postgres psql -U workflow user -d ai workflow < db/001 create work items.sql js cat src/config.ts <<'EOF' import "dotenv/config"; import { z } from "zod"; const schema = z.object { PORT: z.coerce.number .int .min 1 .max 65535 .default 3000 , LOG LEVEL: z.enum "trace", "debug", "info", "warn", "error", "fatal" .default "info" , DATABASE URL: z.string .url , REDIS URL: z.string .url , OPENAI API KEY: z.string .min 1 , OPENAI MODEL: z.string .min 1 , WEBHOOK SHARED SECRET: z.string .min 16 } ; const parsed = schema.safeParse process.env ; if parsed.success { console.error parsed.error.flatten .fieldErrors ; process.exit 1 ; } export const config = parsed.data; EOF cat src/db.ts <<'EOF' import pg from "pg"; import { config } from "./config.js"; export const pool = new pg.Pool { connectionString: config.DATABASE URL, max: 10 } ; EOF cat src/redis.ts <<'EOF' import IORedis from "ioredis"; import { config } from "./config.js"; export const redis = new IORedis config.REDIS URL, { maxRetriesPerRequest: null } ; EOF cat src/schemas.ts <<'EOF' import { z } from "zod"; export const inputSchema = z.object { idempotencyKey: z.string .min 8 .max 200 , source: z.string .min 2 .max 100 , title: z.string .min 3 .max 300 , body: z.string .min 10 .max 20000 , metadata: z.record z.string , z.unknown .default {} } ; export const resultSchema = z.object { category: z.enum "billing", "bug", "feature request", "security", "account access", "incident", "documentation", "other" , priority: z.enum "low", "medium", "high", "critical" , assignedTeam: z.enum "support", "engineering", "security", "sre", "finance", "product" , summary: z.string .min 1 .max 700 , recommendedAction: z.string .min 1 .max 1000 , needsHumanReview: z.boolean } ; export type WorkItemJob = { workItemId: string }; EOF The API accepts a shared secret for local demonstration. Production integrations should use the sender’s supported authentication method, such as timestamped signature verification, OAuth, mTLS, or signed JWT validation. Do not commit credentials or use this static local secret in production. python cat src/api.ts <<'EOF' import crypto from "node:crypto"; import express from "express"; import { Queue } from "bullmq"; import pino from "pino"; import pinoHttp from "pino-http"; import { ZodError } from "zod"; import { config } from "./config.js"; import { pool } from "./db.js"; import { redis } from "./redis.js"; import { inputSchema, type WorkItemJob } from "./schemas.js"; const logger = pino { level: config.LOG LEVEL } ; const queue = new Queue