Back to Blog
Bull Board Production Setup: Monitoring BullMQ Queues

Bull Board Production Setup: Monitoring BullMQ Queues

January 3, 2026
Stefan Mentović
bullmqmonitoringbull-boardobservabilitynodejs

Set up production-ready queue monitoring with Bull Board. Learn authentication, BullMQ terminology, queue architecture trade-offs, and alerting best practices.

#Bull Board Production Setup: Monitoring BullMQ Queues

Your job queue just failed silently in production. By the time users started complaining, 500 jobs were stuck in a deadlock, and you had no visibility into what went wrong.

If you're running BullMQ in production without proper monitoring, you're flying blind. Jobs fail, queues stall, memory leaks develop—and without observability, you won't know until it's too late. The good news? Bull Board gives you a production-grade dashboard for BullMQ queues in under 30 minutes of setup.

This guide covers Bull Board setup, BullMQ terminology, queue architecture decisions, authentication patterns, and monitoring best practices that actually work in production.

#Why Bull Board?

Before diving into setup, let's understand why Bull Board is the most popular choice for BullMQ monitoring—and when you might consider alternatives.

#Bull Board Advantages

  • Free and open source: No licensing costs, MIT license
  • Framework agnostic: Works with Express, Fastify, Hono, Koa, and more
  • Real-time updates: WebSocket-based live job status
  • Job management: Retry, remove, promote jobs directly from UI
  • Low overhead: Lightweight React dashboard, minimal resource usage
  • Active maintenance: Regular updates, responsive maintainer

#Bull Board Limitations

  • Basic authentication only: No built-in OAuth or SSO
  • Single-instance view: No cross-cluster aggregation
  • Limited historical data: Shows current state, not trends over time
  • No alerting built-in: Requires external monitoring integration

#Alternative Dashboard Options

  • Bull Board (Free, self-hosted) - Best for small-medium teams who are cost-conscious. The standard choice for most projects.
  • Taskforce.sh ($29-299/mo, SaaS) - Best for teams wanting a managed solution with SSO, team management, and historical analytics without maintaining infrastructure.
  • Arena (Free, self-hosted) - Best for legacy Bull v3 users. Not recommended for new BullMQ projects.
  • Bull Monitor (Free, self-hosted) - Best for teams preferring a minimalist UI.
  • Grafana + Prometheus (Free, self-hosted) - Best for teams with an existing observability stack who want unified monitoring.

For most teams, Bull Board provides 90% of needed functionality at zero cost.

#BullMQ Terminology: Understanding the Components

Before setting up monitoring, let's clarify BullMQ's core concepts — confusion here leads to architectural mistakes.

#Queue

A Queue is a named job container backed by Redis. It's the coordination point between producers and consumers:

import { Queue } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };

const orderQueue = new Queue('order-processing', { connection });

Queues are lightweight—creating a Queue instance doesn't start processing, it just establishes a connection to Redis.

#Producer

A Producer adds jobs to a queue. Any code with queue access can be a producer:

// Adding a job (producing)
await orderQueue.add(
	'process-order', // Job name
	{ orderId: 'ORD-12345', customerId: 'CUS-789' }, // Job data
	{ priority: 1, attempts: 3 }, // Job options
);

#Worker

A Worker processes jobs from a queue. This is where your business logic runs:

import { Worker, Job } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };

const orderWorker = new Worker(
	'order-processing', // Must match queue name
	async (job: Job) => {
		// This is the processor function
		const { orderId, customerId } = job.data;
		await processOrder(orderId, customerId);
		return { processed: true };
	},
	{ connection, concurrency: 5 },
);

#Processor

The Processor is the async function passed to a Worker that handles each job. It receives a Job object and returns a result (or throws an error):

// The processor function
async function processOrder(job: Job) {
	await job.updateProgress(10);
	const order = await fetchOrder(job.data.orderId);

	await job.updateProgress(50);
	await chargePayment(order);

	await job.updateProgress(100);
	return { success: true, chargedAmount: order.total };
}

// Pass processor to worker
const worker = new Worker('order-processing', processOrder, { connection });

#Flow / FlowProducer

A Flow defines parent-child job relationships where parent jobs wait for children to complete:

import { FlowProducer } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };
const flowProducer = new FlowProducer({ connection });

// Create a flow: parent waits for all children to complete
await flowProducer.add({
	name: 'fulfill-order',
	queueName: 'order-fulfillment',
	data: { orderId: 'ORD-12345' },
	children: [
		{ name: 'charge-payment', queueName: 'payments', data: { amount: 99.99 } },
		// ... additional child jobs
	],
});

#QueueEvents

QueueEvents provides an event emitter for job lifecycle events—useful for monitoring and logging:

import { QueueEvents } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };
const queueEvents = new QueueEvents('order-processing', { connection });

queueEvents.on('completed', ({ jobId, returnvalue }) => {
	console.log(`Job ${jobId} completed with result:`, returnvalue);
});

queueEvents.on('failed', ({ jobId, failedReason }) => {
	console.error(`Job ${jobId} failed:`, failedReason);
});

// ... other event handlers: 'stalled', 'progress', 'waiting', etc.

#Queue Architecture: Single vs Multiple Queues

A critical architectural decision is whether to use one queue for all job types or separate queues. Both approaches have trade-offs.

#Single Queue with Job Names

const connection = { host: 'localhost', port: 6379 };

// One queue, multiple job types
const jobQueue = new Queue('jobs', { connection });

await jobQueue.add('send-email', { to: 'user@example.com', template: 'welcome' });
await jobQueue.add('generate-report', { reportId: 'RPT-001' });
// ... additional job types

// Worker handles all job types via switch statement
const worker = new Worker(
	'jobs',
	async (job) => {
		switch (job.name) {
			case 'send-email':
				return sendEmail(job.data);
			case 'generate-report':
				return generateReport(job.data);
			// ... additional job handlers
			default:
				throw new Error(`Unknown job type: ${job.name}`);
		}
	},
	{ connection },
);

Pros:

  • Simpler Redis footprint (fewer keys)
  • Easier to manage in small applications
  • Single worker deployment

Cons:

  • No independent scaling per job type
  • Slow jobs block fast jobs (head-of-line blocking)
  • Harder to set different retry policies per job type
  • One failure mode affects all job types

#Multiple Dedicated Queues

const connection = { host: 'localhost', port: 6379 };

// Separate queues per job type
const emailQueue = new Queue('emails', { connection });
const reportQueue = new Queue('reports', { connection });
// ... additional queues

// Separate workers with appropriate configuration
const emailWorker = new Worker('emails', sendEmail, {
	connection,
	concurrency: 20, // Emails are fast, high concurrency
});

const reportWorker = new Worker('reports', generateReport, {
	connection,
	concurrency: 2, // Reports are slow, limit concurrency
});

// ... additional workers with queue-specific concurrency settings

Pros:

  • Independent scaling per job type
  • Different retry strategies per queue
  • Isolation—one queue's issues don't affect others
  • Clearer monitoring and alerting per job type
  • Can pause/resume queues independently

Cons:

  • More Redis connections (important for connection-limited Redis)
  • More complex deployment (multiple workers)
  • More Bull Board adapters to configure

#Recommendation

Use multiple queues when:

  • Job types have different processing characteristics (fast vs slow)
  • You need independent scaling or rate limiting
  • Isolation between job types matters (payments shouldn't be blocked by report generation)
  • You have more than 3-4 distinct job types

Use single queue when:

  • Building a simple application with few job types
  • All jobs have similar processing time and requirements
  • Connection limits are a concern (e.g., Azure Redis Basic tier)

#Setting Up Bull Board

With terminology and architecture understood, let's set up Bull Board. We'll use Hono, but patterns apply to any framework.

npm install @bull-board/api @bull-board/hono bullmq hono

#Basic Setup (Development Only)

This example works for local development but is not production-ready:

// ⚠️ DEVELOPMENT ONLY - No authentication
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { Queue } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };

// E-commerce order processing queues
const orderQueue = new Queue('order-processing', { connection });
// ... additional queues: payments, fulfillment, notifications

const app = new Hono();
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath('/admin/queues');

createBullBoard({
	queues: [new BullMQAdapter(orderQueue) /* ... other queue adapters */],
	serverAdapter,
});

app.route('/admin/queues', serverAdapter.registerPlugin());

serve({ fetch: app.fetch, port: 3000 });
// Dashboard at http://localhost:3000/admin/queues

Why this isn't production-ready:

  • No authentication—anyone can access the dashboard
  • No HTTPS—credentials transmitted in plain text
  • No rate limiting—vulnerable to brute force
  • Exposes job data including potentially sensitive information

#Production Authentication Patterns

#Pattern 1: Basic Authentication (Minimum Viable)

Basic auth is the simplest approach but has limitations. Use only over HTTPS:

import { basicAuth } from 'hono/basic-auth';

const app = new Hono();
const basePath = '/admin/queues';

// Environment variables (never hardcode credentials)
const BULL_BOARD_USER = process.env.BULL_BOARD_USER;
const BULL_BOARD_PASS = process.env.BULL_BOARD_PASS;

if (!BULL_BOARD_USER || !BULL_BOARD_PASS) {
	throw new Error('BULL_BOARD_USER and BULL_BOARD_PASS are required');
}

// Apply basic auth to Bull Board routes
app.use(
	`${basePath}/*`,
	basicAuth({
		username: BULL_BOARD_USER,
		password: BULL_BOARD_PASS,
	}),
);

// Mount Bull Board after auth middleware
app.route(basePath, serverAdapter.registerPlugin());

Limitations of basic auth:

  • Credentials sent with every request (must use HTTPS)
  • No session management—can't revoke access without changing password
  • No audit trail of who accessed the dashboard
  • Single shared credential for all users

For production, implement proper session-based auth with your existing auth system:

import { Hono } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { sign, verify } from 'hono/jwt';

const app = new Hono();
const basePath = '/admin/queues';

const JWT_SECRET = process.env.JWT_SECRET!;
const ADMIN_USERS = (process.env.ADMIN_USERS || '').split(','); // List of allowed emails

// Login page
app.get('/admin/login', (c) => {
	return c.html(`
    <html>
      <body>
        <h1>Bull Board Login</h1>
        <form method="POST" action="/admin/login">
          <input type="email" name="email" placeholder="Email" required />
          <input type="password" name="password" placeholder="Password" required />
          <button type="submit">Login</button>
        </form>
      </body>
    </html>
  `);
});

// Login handler - integrate with your auth system
app.post('/admin/login', async (c) => {
	const { email, password } = await c.req.parseBody();

	// Verify credentials against your auth system
	const isValid = await verifyCredentials(email as string, password as string);

	if (!isValid || !ADMIN_USERS.includes(email as string)) {
		return c.redirect('/admin/login?error=unauthorized');
	}

	// Create session token
	const token = await sign(
		{
			email,
			role: 'admin',
			exp: Math.floor(Date.now() / 1000) + 60 * 60 * 8, // 8 hours
		},
		JWT_SECRET,
	);

	setCookie(c, 'bull_board_session', token, {
		httpOnly: true,
		secure: process.env.NODE_ENV === 'production',
		sameSite: 'Strict',
		maxAge: 60 * 60 * 8,
	});

	return c.redirect(basePath);
});

// Logout
app.get('/admin/logout', (c) => {
	deleteCookie(c, 'bull_board_session');
	return c.redirect('/admin/login');
});

// Auth middleware for Bull Board
app.use(`${basePath}/*`, async (c, next) => {
	const token = getCookie(c, 'bull_board_session');

	if (!token) {
		return c.redirect('/admin/login');
	}

	try {
		const payload = await verify(token, JWT_SECRET);

		// Optional: Add audit logging
		console.log(`Bull Board accessed by ${payload.email} at ${new Date().toISOString()}`);

		await next();
	} catch {
		deleteCookie(c, 'bull_board_session');
		return c.redirect('/admin/login');
	}
});

app.route(basePath, serverAdapter.registerPlugin());

#Pattern 3: Reverse Proxy Authentication (Enterprise)

For enterprise deployments, handle authentication at the infrastructure level:

# nginx.conf - OAuth2 Proxy integration
location /admin/queues {
    auth_request /oauth2/auth;
    error_page 401 = /oauth2/sign_in;

    # Pass user info to application
    auth_request_set $user $upstream_http_x_auth_request_user;
    proxy_set_header X-Authenticated-User $user;

    proxy_pass http://api-server:3000;
}

This approach integrates with:

  • OAuth2 Proxy for Google/GitHub/Azure AD SSO
  • Keycloak for enterprise identity management
  • Cloudflare Access for zero-trust access
  • AWS ALB with Cognito integration

#Business Case Example: E-Commerce Order Processing

Let's build a realistic Bull Board setup for an e-commerce platform with proper queue hierarchy:

import { Queue } from 'bullmq';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';

const connection = { host: process.env.REDIS_HOST, port: 6379 };

// Define queue hierarchy for e-commerce order processing
const queues = {
	// === ORDER LIFECYCLE ===
	orderOrchestrator: {
		queue: new Queue('order-orchestrator', { connection }),
		displayName: '📦 Order Orchestrator',
		description: 'Coordinates order processing workflow.',
	},

	// === PAYMENT PROCESSING ===
	paymentProcessing: {
		queue: new Queue('payment-processing', { connection }),
		displayName: '└─ 💳 Payment Processing',
		description: 'Charges customer payment methods via Stripe.',
	},
	// ... paymentRefunds, paymentDisputes queues

	// === INVENTORY MANAGEMENT ===
	inventoryReservation: {
		queue: new Queue('inventory-reservation', { connection }),
		displayName: '└─ 📋 Inventory Reservation',
		description: 'Reserves inventory for orders.',
	},
	// ... inventorySync, inventoryAlerts queues

	// === FULFILLMENT ===
	fulfillmentRouting: {
		queue: new Queue('fulfillment-routing', { connection }),
		displayName: '└─ 🚚 Fulfillment Routing',
		description: 'Determines optimal fulfillment center.',
	},
	// ... shippingLabels, carrierTracking queues

	// === CUSTOMER COMMUNICATION ===
	emailNotifications: {
		queue: new Queue('email-notifications', { connection }),
		displayName: '📧 Email Notifications',
		description: 'Sends transactional emails.',
	},
	// ... smsNotifications, pushNotifications queues

	// === ANALYTICS & REPORTING ===
	analyticsEvents: {
		queue: new Queue('analytics-events', { connection }),
		displayName: '📊 Analytics Events',
		description: 'Processes analytics events for dashboards.',
	},
	// ... reportGeneration, dataExport queues
};

// Create Bull Board adapters with metadata
const queueAdapters = Object.values(queues).map(
	({ queue, displayName, description }) => new BullMQAdapter(queue, { displayName, description }),
);

createBullBoard({
	queues: queueAdapters,
	serverAdapter,
	options: {
		uiConfig: {
			boardTitle: 'Acme Commerce - Queue Monitor',
			miscLinks: [
				{ text: 'API Health', url: `${process.env.API_URL}/health` },
				{ text: 'Grafana Dashboards', url: process.env.GRAFANA_URL },
				{ text: 'PagerDuty', url: 'https://acme.pagerduty.com' },
			],
		},
	},
});

The tree-like naming (└─, └─) visualizes queue relationships directly in Bull Board's UI.

#Logging Best Practices

Effective logging transforms Bull Board from a debugging tool into a proactive monitoring system.

#Structured Job Logging

import { Worker, Job } from 'bullmq';
import pino from 'pino';

const connection = { host: 'localhost', port: 6379 };

const logger = pino({
	level: process.env.LOG_LEVEL || 'info',
	formatters: {
		level: (label) => ({ level: label }),
	},
});

// Create child logger for queue operations
const queueLogger = logger.child({ component: 'queue' });

const orderWorker = new Worker(
	'order-processing',
	async (job: Job) => {
		const jobLogger = queueLogger.child({
			jobId: job.id,
			jobName: job.name,
			queueName: job.queueName,
			attemptsMade: job.attemptsMade,
		});

		jobLogger.info({ data: job.data }, 'Job started');

		try {
			await job.updateProgress(10);
			jobLogger.debug('Validating order data');

			const order = await validateOrder(job.data);
			await job.updateProgress(30);

			jobLogger.debug({ orderId: order.id }, 'Processing payment');
			const payment = await processPayment(order);
			await job.updateProgress(60);

			jobLogger.debug('Reserving inventory');
			await reserveInventory(order);
			await job.updateProgress(90);

			const result = { orderId: order.id, paymentId: payment.id };
			jobLogger.info({ result, durationMs: Date.now() - job.timestamp }, 'Job completed');

			return result;
		} catch (error) {
			jobLogger.error(
				{
					error: error.message,
					stack: error.stack,
					attemptsMade: job.attemptsMade,
					maxAttempts: job.opts.attempts,
				},
				'Job failed',
			);
			throw error;
		}
	},
	{ connection, concurrency: 5 },
);

#QueueEvents for Centralized Monitoring

import { QueueEvents } from 'bullmq';

const connection = { host: 'localhost', port: 6379 };
const queueNames = ['order-processing', 'payment-processing' /* ... other queues */];

// Set up centralized event monitoring for all queues
queueNames.forEach((queueName) => {
	const events = new QueueEvents(queueName, { connection });

	events.on('completed', ({ jobId, returnvalue }) => {
		logger.info({ queueName, jobId, returnvalue }, 'Job completed');
		metrics.jobsCompleted.inc({ queue: queueName });
	});

	events.on('failed', ({ jobId, failedReason }) => {
		logger.error({ queueName, jobId, failedReason }, 'Job failed');
		metrics.jobsFailed.inc({ queue: queueName });
	});

	events.on('stalled', ({ jobId }) => {
		logger.warn({ queueName, jobId }, 'Job stalled - worker may have crashed');
		alerting.sendAlert({
			severity: 'warning',
			title: `Stalled job in ${queueName}`,
			message: `Job ${jobId} has stalled.`,
		});
	});

	// ... other event handlers: 'progress', 'waiting', 'delayed'
});

#Correlation IDs for Request Tracing

Link jobs to originating HTTP requests for end-to-end tracing:

// HTTP endpoint that creates a job
app.post('/api/orders', async (c) => {
	const correlationId = c.req.header('x-correlation-id') || crypto.randomUUID();
	const orderData = await c.req.json();

	await orderQueue.add(
		'process-order',
		{
			...orderData,
			_meta: {
				correlationId,
				initiatedBy: c.get('userId'),
				initiatedAt: new Date().toISOString(),
				source: 'api',
			},
		},
		{ jobId: `order-${orderData.orderId}-${Date.now()}` },
	);

	return c.json({ correlationId, status: 'processing' });
});

// Worker logs include correlation ID
const worker = new Worker(
	'order-processing',
	async (job) => {
		const { _meta, ...orderData } = job.data;

		const jobLogger = logger.child({
			correlationId: _meta.correlationId,
			jobId: job.id,
			initiatedBy: _meta.initiatedBy,
		});

		jobLogger.info('Processing order from API request');
		// ... processing logic
	},
	{ connection },
);

#Monitoring and Alerting

Bull Board provides visibility, but you need proactive alerting for production incidents.

#Health Check Endpoint

interface QueueHealth {
	name: string;
	status: 'healthy' | 'degraded' | 'critical';
	metrics: {
		waiting: number;
		active: number;
		completed: number;
		failed: number;
		delayed: number;
	};
	alerts: string[];
}

app.get('/health/queues', async (c) => {
	const queueList = Object.values(queues).map((q) => q.queue);

	const health: QueueHealth[] = await Promise.all(
		queueList.map(async (queue) => {
			const counts = await queue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
			const alerts: string[] = [];
			let status: 'healthy' | 'degraded' | 'critical' = 'healthy';

			// Check for concerning metrics
			if (counts.waiting > 1000) {
				alerts.push(`High backlog: ${counts.waiting} waiting jobs`);
				status = 'degraded';
			}

			if (counts.waiting > 5000) {
				status = 'critical';
			}

			const totalProcessed = counts.completed + counts.failed;
			if (totalProcessed > 100) {
				const failureRate = counts.failed / totalProcessed;
				if (failureRate > 0.05) {
					alerts.push(`High failure rate: ${(failureRate * 100).toFixed(1)}%`);
					status = status === 'critical' ? 'critical' : 'degraded';
				}
				if (failureRate > 0.2) {
					status = 'critical';
				}
			}

			return {
				name: queue.name,
				status,
				metrics: counts,
				alerts,
			};
		}),
	);

	const overallStatus = health.some((h) => h.status === 'critical')
		? 'critical'
		: health.some((h) => h.status === 'degraded')
		? 'degraded'
		: 'healthy';

	return c.json({
		status: overallStatus,
		timestamp: new Date().toISOString(),
		queues: health,
	});
});

#Prometheus Metrics Export

import { Registry, Gauge, Histogram } from 'prom-client';

const register = new Registry();

const queueDepthGauge = new Gauge({
	name: 'bullmq_queue_depth',
	help: 'Number of jobs in each state',
	labelNames: ['queue', 'state'],
	registers: [register],
});

const jobDurationHistogram = new Histogram({
	name: 'bullmq_job_duration_seconds',
	help: 'Job processing duration',
	labelNames: ['queue', 'job_name', 'status'],
	buckets: [0.1, 0.5, 1, 5, 10, 30, 60, 120],
	registers: [register],
});

// ... additional metrics: jobsProcessed Counter, etc.

// Update metrics every 30 seconds
setInterval(async () => {
	for (const { queue } of Object.values(queues)) {
		const counts = await queue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');

		for (const [state, count] of Object.entries(counts)) {
			queueDepthGauge.set({ queue: queue.name, state }, count);
		}
	}
}, 30_000);

// Expose metrics endpoint
app.get('/metrics', async (c) => {
	const metrics = await register.metrics();
	return c.text(metrics, 200, { 'Content-Type': register.contentType });
});

#Key Takeaways

  • Bull Board is the standard choice for BullMQ monitoring—free, well-maintained, and sufficient for most use cases
  • Understand BullMQ terminology: Queue, Worker, Processor, Producer, Flow—confusion here leads to architectural mistakes
  • Choose queue architecture carefully: Multiple queues provide isolation and independent scaling; single queue simplifies deployment
  • Never deploy without authentication: Basic auth is minimum viable, session-based auth is recommended for production
  • Add rich metadata: Display names and descriptions make dashboards self-documenting
  • Implement structured logging: Include job IDs, correlation IDs, and timing for effective debugging
  • Build proactive alerting: Don't wait for users to report issues—monitor queue depth, failure rates, and stalled jobs
  • Export metrics to your observability stack: Prometheus/Grafana integration provides historical trends Bull Board lacks

#Further Reading

Need help architecting your queue system? Check out our event-driven architecture guide or contact us for consulting on scalable backend systems.

Enjoyed this article? Stay updated: