---
url: /bull-board/guide/introduction.md
---
# Introduction
Bull-Board is a dashboard for [BullMQ](https://docs.bullmq.io/) and [Bull](https://github.com/OptimalBits/bull). It mounts into your existing HTTP server and shows you what's in Redis. You still use Bull or BullMQ to enqueue and process jobs, bull-board only visualises them.
Want to see it before installing? Open the live demo.
## What you get
- A React dashboard for your queues: counts, jobs, logs, live updates.
- Adapters for Express, Fastify, Koa, Hapi, NestJS, Hono, H3, Elysia, Bun.
- Per-queue read-only mode, formatters, external job URLs, and a visibility guard for multi-tenant setups.
- Self-hosted, no telemetry. Runs inside your app, talks to your Redis.
## Next steps
- [Install bull-board](/bull-board/guide/getting-started.md) and wire it into your framework.
- [Build your first dashboard](/bull-board/guide/your-first-dashboard.md) for an end-to-end walkthrough.
---
url: /bull-board/configuration/ui-config.md
---
# UIConfig
> Applies to: all adapters.
`UIConfig` controls the visual shell of the dashboard: title, logo, favicon, locale, polling, misc links. Pass it via `setUIConfig()` on the server adapter, or forward it through `createBullBoard({ options: { uiConfig } })`.
## Usage
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
options: {
uiConfig: {
boardTitle: 'My Queues',
boardLogo: {
path: 'https://cdn.example.com/logo.png',
width: '120px',
height: 32,
},
miscLinks: [{ text: 'Logout', url: '/logout' }],
hideRedisDetails: true,
showMetrics: true,
hideDocsLink: false,
},
},
});
```
`serverAdapter.setUIConfig({ ... })` directly works the same way, `createBullBoard` just forwards `options.uiConfig` to it.
## Fields
All fields are optional. Defaults are applied by `createBullBoard` where noted.
| Field | Type | Default | Description |
| ------------------------------- | --------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `boardTitle` | `string` | `'Bull Dashboard'` | Text in the header and `
` tag. |
| `boardLogo.path` | `string` | — | URL or static path to the logo image (required when `boardLogo` is set). |
| `boardLogo.width` | `number \| string` | — | Logo width (px number or CSS length). |
| `boardLogo.height` | `number \| string` | — | Logo height (px number or CSS length). |
| `miscLinks` | `Array<{ text: string; url: string }>` | `[]` | Extra links in the header menu (logout, etc.). |
| `hideDocsLink` | `boolean` | `false` | Hide the header Docs icon that links to the bull-board documentation site. |
| `queueSortOptions` | `Array<{ key: string; label: string }>` | — | Custom sort keys for the queue list. |
| `favIcon.default` | `string` | `'static/images/logo.svg'` | Favicon when the tab is inactive. |
| `favIcon.alternative` | `string` | `'static/favicon-32x32.png'` | Favicon when jobs are active. |
| `locale.lng` | `string` | — | Initial i18next language code (`'en'`, `'fr'`, `'zh_TW'`). |
| `dateFormats.short` | `string` | — | `date-fns` format string for timestamps that fall on today. |
| `dateFormats.common` | `string` | — | `date-fns` format string for timestamps in the current year. |
| `dateFormats.full` | `string` | — | `date-fns` format string for older timestamps. |
| `pollingInterval.showSetting` | `boolean` | — | Whether the polling interval selector shows in Settings. |
| `pollingInterval.forceInterval` | `number` | — | Forces a polling interval in seconds, overriding the user's choice. |
| `menu.width` | `string` | — | CSS width of the left sidebar (`'280px'`). |
| `overview.groupByDelimiter` | `boolean` | `false` | Sets the initial overview view. When `true`, it starts grouped: collapsible category sections derived from each queue's `delimiter`, mirroring the sidebar tree. It's only a default. Once a user picks flat or grouped in Settings, their choice is remembered and wins. See [Exploring the dashboard](/bull-board/guide/exploring-the-dashboard.md). |
| `sortQueues` | `boolean` | `false` | When `true`, sidebar and overview sort queues alphabetically, groups before standalone queues. Users can toggle this in Settings. |
| `hideRedisDetails` | `boolean` | `false` | Hides the Redis Details button in the header. |
| `showMetrics` | `boolean` | `false` | Shows a per-queue throughput chart (completed/failed per minute). Relies on [BullMQ/Bull metrics collection](https://docs.bullmq.io/guide/metrics) — enable `metrics` on your workers (e.g. `metrics: { maxDataPoints: MetricsTime.ONE_WEEK }`). |
| `showWorkers` | `boolean` | `true` | Reports the workers connected to each queue, and warns when a queue that isn't paused has none. Set to `false` to drop the per-queue `CLIENT LIST` the board otherwise runs on every poll. See [Exploring the dashboard](/bull-board/guide/exploring-the-dashboard.md). |
| `environment.label` | `string` | — | Environment badge text in the header (`'production'`). |
| `environment.color` | `string` | — | Background colour of the environment badge. |
| `environment.textColor` | `string` | — | Text colour of the environment badge. |
| `environment.fontSize` | `string \| number` | — | Font size of the environment badge. |
With `showMetrics` on, each queue view gains a throughput chart of completed and failed jobs per minute over the last hour.


The demo site uses this exact configuration — `{ label: 'demo', color: '#f59f00', textColor: '#000' }`. See it live.
## Source of truth
The authoritative type is in [`packages/api/typings/app.d.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/typings/app.d.ts) (`UIConfig`). Defaults live in [`packages/api/src/index.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/index.ts).
---
url: /bull-board/configuration/production-checklist.md
---
# Production checklist
Quick gate before exposing the dashboard to real traffic.
## Auth
The dashboard has no built-in auth. Add one. See [Add basic auth](/bull-board/recipes/basic-auth.md). Serving a token-based SPA? See [auto-login from a token-based frontend](/bull-board/recipes/basic-auth.md#auto-login-from-a-token-based-frontend).
## CSRF
Destructive actions (retry, clean, pause, drain, obliterate) are state-changing requests. Behind a browser session, they're CSRF-reachable. See [CSRF protection](/bull-board/recipes/csrf-protection.md).
## Read-only where possible
If the audience is "everyone with a login", most of them don't need `obliterate`. Mark queues [read-only](/bull-board/recipes/read-only-mode.md) and enable retries only on the queues that need them.
## Multi-tenancy
Running a shared dashboard across tenants? Add a [visibility guard](/bull-board/recipes/visibility-guard.md). Don't rely on "nobody will guess the queue name" — the name is in the URL.
## Polling interval
Default is 5 seconds. If the dashboard has many open tabs, every tab polls every queue. Cap it with `pollingInterval.forceInterval` or leave the picker on so users can dial it down. See [Polling interval](/bull-board/recipes/change-polling-interval.md).
## Redis access
The dashboard connects directly to Redis via your queue instances. If Redis is reachable from the dashboard process, the dashboard can do anything Redis permits. Treat dashboard access as Redis access.
## Environment badge
Production and staging sharing a browser? Set `environment.label = 'production'` with a red color on the production instance. Small thing, saves a real outage.
```ts
serverAdapter.setUIConfig({
environment: { label: 'production', color: '#cc0000' },
});
```
## Version pinning
The dashboard talks to your Bull / BullMQ instances. Mismatched versions across workers and the dashboard have bitten people (see issues #1074, #1088, #1097). Pin the same BullMQ across both.
## Logs
Workers that call `job.log()` will have their lines visible in the dashboard under each job's Logs tab. If you're wondering "what did this job do before it failed", that's the answer. See [Job logs and flows](/bull-board/recipes/job-logs-and-flows.md).
## Alerting
The dashboard won't page you. It only shows state while a tab is open. Wire failure alerts to BullMQ's own events, not to bull-board. See [Alerting on failed jobs](/bull-board/recipes/alerting.md).
---
url: /bull-board/guide/ai-agent-setup.md
---
# Set up with an AI agent
If you work with a coding agent (Claude Code, Cursor, Copilot, Windsurf, whatever), you don't have to hand-wire bull-board. Paste the prompt below and let the agent do the mechanical part: install the packages, mount the adapter, match the base path. Then read the diff.
## Copy this prompt
```text
Add bull-board to my app so I can inspect my Bull/BullMQ queues in a browser.
Use the current docs at https://felixmosh.github.io/bull-board/llms.txt as the
source of truth. Don't rely on memory, the API has changed across versions.
Requirements:
- Detect my HTTP framework (Express, Fastify, NestJS, Koa, Hapi, Hono, H3,
Elysia, or Bun) and install @bull-board/api plus the matching @bull-board/ adapter.
- Detect whether I use Bull or BullMQ and wrap each existing queue in the right
queue adapter (BullMQAdapter or BullAdapter). Reuse my existing queue
instances and Redis connection, don't create new ones.
- Create the server adapter, call setBasePath('/admin/queues'), pass my queues to
createBullBoard, and mount the router at the SAME path ('/admin/queues'). The
base path and the mount path must match exactly or the assets 404.
- Do not expose it unauthenticated. If I have auth middleware, put the dashboard
behind it; if I don't, add a TODO and tell me, don't invent credentials.
- Show me the diff and the URL to open. Don't add options that aren't in the docs.
```
Swap the base path (`/admin/queues`) for wherever you want the dashboard to live. The agent should handle the rest, including the one thing people get wrong by hand: keeping `setBasePath` and the mount path identical.
## Point your agent at the current docs
This documentation site publishes machine-readable versions of itself, generated on every build:
- [`llms.txt`](https://felixmosh.github.io/bull-board/llms.txt): a concise index of every page.
- [`llms-full.txt`](https://felixmosh.github.io/bull-board/llms-full.txt): the full text of the docs in one file.
Feed either to an agent (or an "ask the docs" tool) so it works from the current API surface instead of whatever it remembers from training. The `llms-full.txt` version is the one to use when you want it to get option names and defaults exactly right.
## After the agent is done
The agent gets you mounted. These are on you:
- [Add auth](/bull-board/recipes/basic-auth.md) before anything reachable from outside localhost. Agents will happily leave the dashboard wide open.
- [Read-only mode](/bull-board/recipes/read-only-mode.md) if the people opening it shouldn't be retrying or obliterating queues.
- [Alerting](/bull-board/recipes/alerting.md): the dashboard shows failures, it doesn't tell you about them. Wire that separately.
If the page loads but looks broken, it's almost always a base-path mismatch. See [Troubleshooting](/bull-board/recipes/troubleshooting.md).
---
url: /bull-board/guide/exploring-the-dashboard.md
---
# Exploring the dashboard
A tour of how the dashboard is laid out and the controls you'll use day to day: grouped queues, the collapsible sidebar, search, and the per-queue info panel.
## Grouping queues by category
When a queue name contains a delimiter, bull-board splits it into a path. A queue named `Emails.Transactional.Welcome` registered with `{ delimiter: '.' }` becomes `Emails › Transactional › Welcome`. The sidebar has always shown this as a tree; the main overview can now show the same structure.

Each category header rolls up the job counts of every queue beneath it, so you can read the health of a whole domain — say, all of `Payments` — without expanding it. Queues with no delimiter stay as plain cards.
Switch between the flat card grid and the grouped view from **Settings → Queues → Group queues by category**. Expand or collapse every section at once with the chevrons in the overview toolbar, and pause or resume a whole category from its header.
To make grouped the starting view for everyone, set it in `UIConfig`. This is only the default: once a user switches it in Settings, their choice is remembered and overrides the config on the next load.
```ts
createBullBoard({
queues: [
new BullMQAdapter(welcomeEmails, { delimiter: '.' }),
new BullMQAdapter(receiptEmails, { delimiter: '.' }),
],
serverAdapter,
options: {
uiConfig: {
overview: { groupByDelimiter: true },
},
},
});
```
Each view remembers which sections you collapsed, independently of the sidebar.
## Collapsing the sidebar
The toggle in the top-left of the header hides the sidebar and gives the content the full width — handy on smaller screens or when you're working inside a single queue. The state is saved to your browser, so the dashboard reopens the way you left it.

## Searching
The filter box at the top of the sidebar matches queues by name and drives both the sidebar tree and the overview at once. Press `⌘K` (or `Ctrl K`) anywhere to jump straight to it — if the sidebar is collapsed, it opens first.
## Schedulers
Queues that register job schedulers get a **Schedulers** entry in the sidebar. It lists every scheduler the board can see, across all queues, with its cron pattern or interval, when it fires next, when it last ran, and how many times it has run.

Last run is not something BullMQ stores. It is read from the pending run of each schedule, which the worker creates as the previous run starts, so a scheduler that has never fired leaves the column empty.
Both times link to the job behind them when there is one to open. The next run always links, since that job is sitting in the delayed set waiting to be picked up, which is also how you can inspect its payload or promote it. The last run links only when the job it produced still exists and the dashboard can name it, which means interval schedules whose previous run has not been trimmed away by `removeOnComplete`. Naming the previous run of a cron schedule would mean parsing the pattern backwards, so those show the time alone.
Each row can be removed, which stops the schedule and its pending run together, or edited to change the cron pattern, interval, time zone, run limit or end date. Editing only rewrites the schedule: the job the scheduler produces keeps the name, data and options your application registered. Both actions respect `readOnlyMode`, and editing is unavailable on legacy Bull queues, which have no way to update a repeatable job in place.
### These are operational changes, not configuration
Most applications register their schedulers on start, and `upsertJobScheduler` overrides whatever is stored. So a schedule you edit here lasts until the next deploy or restart, at which point your application's own definition wins again, and a scheduler you remove comes back the same way. That makes the view a good place to stop a misbehaving cron or move it a few hours while you fix the job, and a poor place to make a change you expect to keep. Change the code for that.
The exception is a scheduler your application created dynamically and never re-registers, for a single tenant say. Nothing brings that one back, so removing it is permanent. bull-board cannot tell the two apart, which is why the confirmation says the runs stop until the application registers the scheduler again rather than promising either outcome.
Removing a scheduler takes its pending run with it. Completed and failed runs from the past stay where they are, and a run already being processed finishes normally.
Opening a queue that has schedulers shows a link into the same view, filtered to that queue.
## Queue info
Open any queue and click the info icon next to its name.

It opens a panel showing how the queue is configured: type, paused state, global concurrency, how many workers are connected, and the default job options (attempts, backoff, retention), so you don't have to dig through code.

## Connected workers
A queue with a growing waiting count looks the same whether it is simply busy or whether every worker consuming it has died. The dashboard tells the two apart.
Nothing appears while a queue is healthy. A badge shows up only once a queue has no workers connected and is not paused, on its overview card and beside the status tabs on its own page, so the only thing the dashboard ever spends room on is the case worth acting on. A paused queue is meant to have nothing consuming it, so it never warns.

On the queue page it sits with the status tabs, next to the backlog it explains.

Who is actually connected lives in the queue info panel, under **Connected workers**, with the count beside global concurrency in the overview. Open it from the info icon next to the queue name, or by clicking the badge, which drops you straight onto that section.

Each worker leads with whatever identifies it, which is the name you gave it (`new Worker(queueName, processor, { name: 'mailer-1' })`) or its address when you gave it none, and carries the address and how long it has been connected underneath. The badge only exists while something is wrong, so this panel is how you check on a queue that looks fine.
The list comes from Redis `CLIENT LIST`, which is how both Bull and BullMQ implement `getWorkers()`. Some hosted Redis providers block that command, Google Memorystore among them. There the dashboard says nothing about workers at all, in the badge or the info panel, rather than reporting a queue as having none.
Whether a queue has workers rides along with the queue listing the dashboard already polls, so the warning costs no request of its own. Who they are is asked for once, when you open the info panel. What it does cost is one `CLIENT LIST` per queue per poll, which is cheap on a handful of queues and less so on a board with dozens, so it can be switched off wholesale with `showWorkers: false` in [UIConfig](/bull-board/configuration/ui-config.md). That stops the command being run rather than just hiding the badge.
---
url: /bull-board/guide/getting-started.md
---
# Installation
Install the core `@bull-board/api` plus one adapter for your framework.
## Prerequisites
- Node.js 18+ or Bun 1.x
- A running Redis instance
- [Bull](https://github.com/OptimalBits/bull) or [BullMQ](https://docs.bullmq.io/) already set up in your app
::: tip Sharing an ioredis connection?
bull-board reads your existing Bull/BullMQ queues, so it inherits their Redis connection. If you construct BullMQ with a shared `ioredis` instance rather than a plain `{ host, port }`, BullMQ requires that connection to be created with `maxRetriesPerRequest: null`. This is a BullMQ requirement, not a bull-board one, but it's the most common setup snag. See the [BullMQ connection docs](https://docs.bullmq.io/guide/connections).
:::
## Install
Pick the adapter that matches your framework:
| Framework | Install command |
| --------- | ------------------------------------------------- |
| Express | `npm install @bull-board/api @bull-board/express` |
| Fastify | `npm install @bull-board/api @bull-board/fastify` |
| NestJS | `npm install @bull-board/api @bull-board/nestjs` |
| Koa | `npm install @bull-board/api @bull-board/koa` |
| Hapi | `npm install @bull-board/api @bull-board/hapi` |
| Hono | `npm install @bull-board/api @bull-board/hono` |
| H3 | `npm install @bull-board/api @bull-board/h3` |
| Elysia | `npm install @bull-board/api @bull-board/elysia` |
| Bun | `npm install @bull-board/api @bull-board/bun` |
## Next steps
- [Build your first dashboard](/bull-board/guide/your-first-dashboard.md) for a framework-agnostic walkthrough.
- Or jump to your adapter: [Express](/bull-board/server-adapters/express.md), [Fastify](/bull-board/server-adapters/fastify.md), [Koa](/bull-board/server-adapters/koa.md), [Hapi](/bull-board/server-adapters/hapi.md), [NestJS](/bull-board/server-adapters/nestjs.md), [Hono](/bull-board/server-adapters/hono.md), [H3](/bull-board/server-adapters/h3.md), [Elysia](/bull-board/server-adapters/elysia.md), [Bun](/bull-board/server-adapters/bun.md).
---
url: /bull-board/guide/your-first-dashboard.md
---
# Your first dashboard
Express and BullMQ. Every other adapter follows the same three-step shape, see the [adapter pages](/bull-board/server-adapters/index.md) for specifics.
## 1. Create the queue
```ts [queues.ts]
import { Queue } from 'bullmq';
export const emailQueue = new Queue('emails', {
connection: { host: 'localhost', port: 6379 },
});
```
## 2. Mount the dashboard
```ts
import express from 'express';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { emailQueue } from './queues';
const app = express();
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
});
app.use('/admin/queues', serverAdapter.getRouter());
app.listen(3000, () => {
console.log('Dashboard: http://localhost:3000/admin/queues');
});
```
::: tip
`setBasePath` and the `app.use` mount path must match. Change one, change the other, otherwise asset and API URLs will 404.
:::
## 3. Open the dashboard
Start the server and visit `http://localhost:3000/admin/queues`. You'll see the `emails` queue with counts, an empty job list, and a live-updating header.
Add a job:
```ts
await emailQueue.add('welcome', { to: 'you@example.com' });
```
## Where to next
- Add more queues: pass them to `createBullBoard({ queues: [...] })`, or [add and remove them at runtime](/bull-board/recipes/manage-queues-at-runtime.md).
- Lock the dashboard with [read-only mode](/bull-board/recipes/read-only-mode.md).
- Scope queues per tenant with a [visibility guard](/bull-board/recipes/visibility-guard.md).
- Change title, logo, polling via [UIConfig](/bull-board/configuration/ui-config.md).
- Rewrite job fields for the UI with [formatters](/bull-board/recipes/formatters.md).
---
url: /bull-board/index.md
---
# Bull-Board
Dashboard for BullMQ, BullMQ Pro & Bull
> Plug it into your server. See queues, jobs, logs. Pause, retry, clean from one UI.
[Get Started](/guide/introduction) | [Try the demo](/demo/) | [View on GitHub](https://github.com/felixmosh/bull-board)
## Features
- 🧩 **Works with your stack**: Adapters for Express, Fastify, Koa, Hapi, NestJS, Hono, H3, Elysia, and Bun.
- 🔒 **Read-only mode**: Share the dashboard without giving anyone a retry button.
- 🏢 **Multi-tenant**: Scope queue visibility per request with a visibility guard.
- 🎨 **Formatters**: Rewrite how job data is shown without touching your producers.
- ⚡ **BullMQ, BullMQ Pro & Bull**: All three queue adapters ship in the core package.
- 📦 **Self-hosted**: Runs in your app, talks to your Redis. No telemetry, no third parties.
---
url: /bull-board/queue-adapters/bull.md
---
# BullAdapter
For the [Bull](https://github.com/OptimalBits/bull) queue library.
## Import
```ts
import { BullAdapter } from '@bull-board/api/bullAdapter';
// or
const { BullAdapter } = require('@bull-board/api/bullAdapter');
```
## Usage
```ts
import { createBullBoard } from '@bull-board/api';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { ExpressAdapter } from '@bull-board/express';
import Queue from 'bull';
const myQueue = new Queue('my-queue', {
redis: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullAdapter(myQueue)],
serverAdapter,
});
```
## Options
All options are optional.
| Option | Type | Default | Description |
| ---------------- | --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `readOnlyMode` | `boolean` | `false` | Hides all queue and job actions. |
| `allowRetries` | `boolean` | `true` | Shows or hides the retry buttons on **failed** jobs. Forced to `false` when `readOnlyMode` is `true`. |
| `description` | `string` | `''` | Queue description text displayed in the UI. |
| `displayName` | `string` | `''` | Overrides the queue name shown in the UI. |
| `prefix` | `string` | `''` | Prepended to job names in the UI. |
| `delimiter` | `string` | `''` | Delimiter between the prefix and the job name. |
| `externalJobUrl` | `(job) => { href, displayText? }` | none | Links each job card to a page in your own app. See [External job URLs](/bull-board/recipes/external-job-url.md). |
::: tip
`allowCompletedRetries` (available on [`BullMQAdapter`](/bull-board/queue-adapters/bullmq.md)) has no effect here. Bull can't retry completed jobs, so it's always off.
:::
## Instance methods
```ts
adapter.setFormatter('name', (job) => `#${job.name}`);
adapter.setFormatter('data', (data) => redact(data));
adapter.setFormatter('returnValue', (value) => redact(value));
adapter.setFormatter('progress', (progress) => `${Math.round(progress)}%`);
adapter.setVisibilityGuard((request) => {
// return true to show this queue, false to hide it
return request.headers['x-tenant-id'] === 'acme';
});
```
---
url: /bull-board/queue-adapters/bullmq-pro.md
---
# BullMQ Pro
For the [BullMQ Pro](https://docs.bullmq.io/bullmq-pro/introduction) queue library. Import `BullMQProAdapter` instead of `BullMQAdapter` to get awareness of Pro groups: group counts are folded into the `waiting`/`delayed`/`paused` job counts, jobs from `waiting`/`limited`/`maxed`/`paused` groups are listed alongside regular jobs in those tabs, and the group id is shown next to the job name in the UI.
## Install
```sh
npm install @bull-board/api @bull-board/express
```
## Usage
```js
const { QueuePro } = require('@taskforcesh/bullmq-pro');
const { createBullBoard } = require('@bull-board/api');
const { BullMQProAdapter } = require('@bull-board/api/bullMQProAdapter');
const { ExpressAdapter } = require('@bull-board/express');
const queuePro = new QueuePro('queueProName');
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQProAdapter(queuePro)],
serverAdapter,
});
```
All `BullMQAdapter` options (`readOnlyMode`, `allowRetries`, `description`, `prefix`, `setFormatter`, `setVisibilityGuard`) work the same way on `BullMQProAdapter`.
---
url: /bull-board/queue-adapters/bullmq.md
---
# BullMQAdapter
For the [BullMQ](https://docs.bullmq.io/) queue library.
## Import
```ts
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
// or
const { BullMQAdapter } = require('@bull-board/api/bullMQAdapter');
```
## Usage
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { Queue } from 'bullmq';
const myQueue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(myQueue)],
serverAdapter,
});
```
## Options
All options are optional.
| Option | Type | Default | Description |
| ----------------------- | --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `readOnlyMode` | `boolean` | `false` | Hides all queue and job actions. |
| `allowRetries` | `boolean` | `true` | Shows or hides the retry buttons on **failed** jobs. Forced to `false` when `readOnlyMode` is `true`. |
| `allowCompletedRetries` | `boolean` | `true` | Shows or hides the retry button on **completed** jobs. Only takes effect when `allowRetries` is `true`. |
| `description` | `string` | `''` | Queue description text displayed in the UI. |
| `displayName` | `string` | `''` | Overrides the queue name shown in the UI. |
| `prefix` | `string` | `''` | Prepended to job names in the UI. |
| `delimiter` | `string` | `''` | Delimiter between the prefix and the job name. |
| `externalJobUrl` | `(job) => { href, displayText? }` | none | Links each job card to a page in your own app. See [External job URLs](/bull-board/recipes/external-job-url.md). |
## Instance methods
```ts
adapter.setFormatter('name', (job) => `#${job.name}`);
adapter.setFormatter('data', (data) => redact(data));
adapter.setFormatter('returnValue', (value) => redact(value));
adapter.setFormatter('progress', (progress) => `${Math.round(progress)}%`);
adapter.setVisibilityGuard((request) => {
// return true to show this queue, false to hide it
return request.headers['x-tenant-id'] === 'acme';
});
```
## Flow tree
The flow tree tab on the job detail page works with `BullMQAdapter` queues automatically. There's nothing to configure. When you open a job that belongs to a [BullMQ flow](https://docs.bullmq.io/guide/flows), bull-board reads the parent/child graph and renders it.
It builds a `FlowProducer` from the Redis connection of a registered `BullMQAdapter` (cached per connection), then walks the job's parent chain across queues to find the flow root. So it works as long as every queue in the flow is registered on the board.
::: tip
The flow tree only spans queues bull-board knows about. If a parent job lives in a queue you didn't pass to `createBullBoard`, the tree stops at the boundary. Register every queue that participates in the flow.
:::
Bull (the legacy library) has no flows, so the tab is BullMQ-only.
---
url: /bull-board/queue-adapters/index.md
---
# Queue Adapters
Queue adapters wrap your Bull or BullMQ queue instances so the board can read and manipulate them. The core `@bull-board/api` ships with three built-in adapters; third-party queue systems can add their own.
## Built-in adapters
| Queue system | Adapter | Docs |
| ------------ | ------------------ | -------------------------------------------------------- |
| Bull | `BullAdapter` | [Bull →](/bull-board/queue-adapters/bull.md) |
| BullMQ | `BullMQAdapter` | [BullMQ →](/bull-board/queue-adapters/bullmq.md) |
| BullMQ Pro | `BullMQProAdapter` | [BullMQ Pro →](/bull-board/queue-adapters/bullmq-pro.md) |
`BullMQProAdapter` extends `BullMQAdapter` to handle [Pro groups](https://docs.bullmq.io/bullmq-pro/introduction). All `BullMQAdapter` options work the same way on it.
## Shared options
All adapters accept the same optional options:
| Option | Type | Default | Description |
| ----------------------- | --------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readOnlyMode` | `boolean` | `false` | Hides all queue and job actions. |
| `allowRetries` | `boolean` | `true` | Shows or hides the retry buttons on **failed** jobs. Forced to `false` when `readOnlyMode` is `true`. |
| `allowCompletedRetries` | `boolean` | `true` | Shows or hides the retry button on **completed** jobs. Only takes effect when `allowRetries` is `true`. Always `false` on `BullAdapter` (Bull can't retry completed jobs). |
| `description` | `string` | `''` | Queue description text displayed in the UI. |
| `displayName` | `string` | `''` | Overrides the queue name shown in the UI. |
| `prefix` | `string` | `''` | Prepended to job names in the UI. |
| `delimiter` | `string` | `''` | Delimiter between the prefix and the job name. |
| `externalJobUrl` | `(job) => { href, displayText? }` | none | Links each job card to a page in your own app. See [External job URLs](/bull-board/recipes/external-job-url.md). |
| `jobDataSchema` | `object` (JSON Schema) | none | Describes the shape of a job's `data`. Drives the **Add job** form: prefills the editor with a starting value and turns on schema-aware autocomplete and validation. See [Job data schema](#job-data-schema). |
## Job data schema
Pass a [JSON Schema](https://json-schema.org/) as `jobDataSchema` to teach the dashboard what a queue's job `data` looks like. The **Add job** form then does three things with it:
- **Prefills** the job data editor with a starting value: the schema's `default`, otherwise its first `examples` entry, otherwise a skeleton built from `properties` (each key seeded with its own `default` or a typed placeholder).
- **Autocompletes** the expected keys as you type, with any `description` shown on hover.
- **Validates** the JSON against the schema inline, flagging missing required fields, wrong types, and unknown keys before you submit.
```ts
new BullMQAdapter(resetPassword, {
jobDataSchema: {
type: 'object',
additionalProperties: false,
required: ['userId', 'email'],
properties: {
userId: { type: 'string', description: 'Internal id of the user requesting the reset.' },
email: { type: 'string', format: 'email', description: 'Address the reset link is sent to.' },
locale: { type: 'string', description: 'BCP-47 locale for the email template.', default: 'en' },
},
},
});
```
The schema is documentation for the dashboard only. It is not enforced by Bull or BullMQ, so keep it in step with what your worker actually expects.
## Instance methods
All adapters expose `setFormatter` and `setVisibilityGuard`:
```ts
adapter.setFormatter('name', (job) => `#${job.name}`);
adapter.setFormatter('data', (data) => redact(data));
adapter.setFormatter('returnValue', (value) => redact(value));
adapter.setFormatter('progress', (progress) => `${Math.round(progress)}%`);
adapter.setVisibilityGuard((request) => {
// return true to show this queue, false to hide it
return request.headers['x-tenant-id'] === 'acme';
});
```
## Mixing adapters
You can mix Bull and BullMQ queues in the same board:
```ts
createBullBoard({
queues: [
new BullAdapter(bullQueue),
new BullMQAdapter(bullmqQueue),
new BullMQProAdapter(bullmqProQueue),
],
serverAdapter,
});
```
---
url: /bull-board/recipes/alerting.md
---
# Alerting on failed jobs
> Applies to: all adapters.
bull-board is a viewer, not a monitor. It shows the state of your queues while you have the tab open. It doesn't watch them for you and it sends nothing anywhere, so "how do I get alerted when a job fails?" isn't a question the dashboard answers. That part is on you, and it belongs in your worker code, not the board.
BullMQ already emits the events you need. Wire your alerting to those, and use bull-board to investigate once an alert fires.
## Alert from the worker (same process)
If the alert lives in the same process as the worker, listen to its `failed` event. In most cases you only want to page once a job has spent all its retries, not on every intermediate attempt:
```ts
import { Worker } from 'bullmq';
const worker = new Worker('emails', processor, { connection });
worker.on('failed', (job, err) => {
// `failed` fires on every attempt. Only alert once retries are exhausted.
const exhausted = !job || job.attemptsMade >= (job.opts.attempts ?? 1);
if (exhausted) {
notifyOnCall(`Job ${job?.id} on "emails" failed for good: ${err.message}`);
}
});
```
Attach an `error` listener too. Without one, an error inside the worker can bubble up as an unhandled exception and take the process down:
```ts
worker.on('error', (err) => {
logger.error({ err }, 'bullmq worker error');
});
```
## Alert from anywhere (cross-process)
Worker events only fire in the process running the worker. If your alerting service runs in a separate process (an API pod, a small dedicated watcher), use `QueueEvents`, which reads the events straight from Redis:
```ts
import { QueueEvents } from 'bullmq';
const events = new QueueEvents('emails', { connection });
events.on('failed', ({ jobId, failedReason }) => {
notifyOnCall(`Job ${jobId} on "emails" failed: ${failedReason}`);
});
events.on('stalled', ({ jobId }) => {
// A worker picked the job up and then went silent (crash, OOM, event-loop block).
notifyOnCall(`Job ${jobId} on "emails" stalled`);
});
```
`QueueEvents` gives you `jobId` and `failedReason`, but not the `Job` instance, so the "retries exhausted?" check from the worker version isn't available here. If you only want the final failure, either keep that logic in the worker, or fetch the job (`queue.getJob(jobId)`) and inspect `attemptsMade` yourself.
## Route permanently-failed jobs to a dead-letter queue
A common pattern: once a job is truly dead, push it onto a separate "dead-letter" queue, then register that queue on the board. Now permanently-failed work has its own inbox you can inspect and replay from the dashboard, instead of hunting through the failed tab of a busy queue.
```ts
const deadLetters = new Queue('emails-dead-letter', { connection });
worker.on('failed', async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await deadLetters.add('dead', { original: job.data, reason: err.message });
}
});
// Register both on the board so the DLQ is one click away.
createBullBoard({
queues: [new BullMQAdapter(emailsQueue), new BullMQAdapter(deadLetters)],
serverAdapter,
});
```
Mark the dead-letter queue [read-only](/bull-board/recipes/read-only-mode.md) if it's only there to be read.
## What the dashboard does give you
Not alerts, but three things worth knowing:
- **No workers warning.** A queue that has nothing consuming it and isn't paused is flagged on the overview, which is the case a rising waiting count on its own can't tell you about. It's on by default and can be turned off with `showWorkers: false` in [UIConfig](/bull-board/configuration/ui-config.md). See [Exploring the dashboard](/bull-board/guide/exploring-the-dashboard.md). Still only visible while someone has the tab open, so it catches a dead worker during triage rather than waking anyone up.
- **Throughput chart.** Set `showMetrics: true` in [UIConfig](/bull-board/configuration/ui-config.md) for a completed/failed-per-minute chart per queue. It needs [BullMQ metrics](https://docs.bullmq.io/guide/metrics) enabled on your workers. Good for spotting a spike after the fact, not for waking anyone up.
- **Job logs.** Lines your worker writes with `job.log()` show up under each job. When an alert points you at a failed job, that's where the "what happened" usually is. See [Job logs and flows](/bull-board/recipes/job-logs-and-flows.md).
## Going deeper
Alerting well (deduping, escalation, telling a stalled worker apart from a genuinely failing job) is a BullMQ and ops concern, not a bull-board one. The BullMQ docs are the source for the event semantics:
- [Events](https://docs.bullmq.io/guide/events): the full `QueueEvents` list.
- [Workers](https://docs.bullmq.io/guide/workers): worker-level listeners and the `error` event.
- [Retrying failing jobs](https://docs.bullmq.io/guide/retrying-failing-jobs): attempts, backoff, and what "failed for good" means.
---
url: /bull-board/recipes/basic-auth.md
---
# Add basic auth
The dashboard has no built-in auth. Don't expose it on the open internet without one. Here's the minimum per framework.
## Express + Passport
From [`examples/with-express-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-express-auth).
```js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const { ensureLoggedIn } = require('connect-ensure-login');
const session = require('express-session');
passport.use(new LocalStrategy((username, password, cb) => {
if (username === 'bull' && password === 'board') {
return cb(null, { user: 'bull-board' });
}
return cb(null, false);
}));
passport.serializeUser((user, cb) => cb(null, user));
passport.deserializeUser((user, cb) => cb(null, user));
app.use(session({ secret: 'keyboard cat', resave: true, saveUninitialized: true }));
app.use(passport.initialize());
app.use(passport.session());
app.post('/ui/login', passport.authenticate('local', { failureRedirect: '/ui/login?invalid=true' }),
(req, res) => res.redirect('/ui'));
app.use('/ui', ensureLoggedIn({ redirectTo: '/ui/login' }), serverAdapter.getRouter());
```
A logged-in session reaches `/ui` without a second login, which is all "auto-login" really means for a cookie-based app. If your API uses bearer tokens instead, see [Auto-login from a token-based frontend](#auto-login-from-a-token-based-frontend).
Run it:
```sh
git clone https://github.com/felixmosh/bull-board
cd bull-board/examples/with-express-auth
npm install && npm start
# http://localhost:3000/ui (login: bull / board)
```
## Fastify + @fastify/basic-auth
From [`examples/with-fastify-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-fastify-auth).
```js
await app.register(require('@fastify/basic-auth'), {
validate: (username, password, req, reply, done) => {
if (username === 'bull' && password === 'board') return done();
done(new Error('Unauthorized'));
},
authenticate: { realm: 'Bull-Board' },
});
app.after(() => {
const serverAdapter = new FastifyAdapter();
createBullBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
serverAdapter.setBasePath('/ui');
app.register(serverAdapter.registerPlugin(), { prefix: '/ui' });
app.addHook('onRequest', (req, reply, next) => {
app.basicAuth(req, reply, (err) => err ? reply.code(401).send({ error: err.name }) : next());
});
});
```
The `onRequest` hook covers every route registered after it. Scope the auth plugin inside a child context if you want it to cover only the dashboard.
## Hapi + strategy
From [`examples/with-hapi-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-hapi-auth).
```js
await app.register(require('@hapi/basic'));
app.auth.strategy('simple', 'basic', {
validate: async (_req, username, password) => ({
isValid: username === 'bull' && password === 'board',
credentials: { username },
}),
});
const serverAdapter = new HapiAdapter();
createBullBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
serverAdapter.setBasePath('/ui');
await app.register(
{ plugin: serverAdapter.registerPlugin(), options: { auth: 'simple' } },
{ routes: { prefix: '/ui' } }
);
```
The plugin options pass straight to Hapi's route config, so the auth strategy applies to every bull-board route.
## NestJS + guards
From [`examples/with-nestjs-fastify-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nestjs-fastify-auth).
NestJS on the Fastify platform with a standard `@UseGuards()` guard. The example uses passport-local plus `@fastify/secure-session` for session cookies.
```ts
@Controller()
export class AppController {
@Post('login')
@UseFilters(AuthExceptionFilter)
@UseGuards(AuthGuard('local'))
login(@Request() req: FastifyRequest, @Response() reply: FastifyReply) {
req.session.set('sev-data', req.user);
return reply.status(302).redirect('/queues');
}
}
```
The dashboard is mounted by `@bull-board/nestjs`, and the module's own guard checks the session before the route resolves.
## Auto-login from a token-based frontend
If your app uses a cookie session, you already have auto-login. The browser sends the session cookie on every request, including when someone opens `/ui`, so a logged-in admin lands on the dashboard through your existing middleware without logging in again. There's nothing else to do.
Bearer tokens are where it gets awkward. When a separate SPA authenticates by sending an `Authorization: Bearer` header, opening `/ui` in a new tab won't carry that header. It's just a normal browser navigation, so your token middleware turns it away. What the dashboard needs is a cookie the browser will send on its own.
So give it one. Add an admin-only endpoint that logs the user into a session and hands back the URL:
```js
// Guarded by your normal bearer-token middleware, admin only.
app.get('/api/queue-monitor', requireAdmin, (req, res) => {
req.login(req.user, (err) => { // sets the session cookie
if (err) return res.status(500).end();
res.json({ url: `${req.protocol}://${req.get('host')}/ui` });
});
});
```
The SPA calls it with its token and opens the URL it gets back:
```js
const { url } = await api.get('/api/queue-monitor');
window.open(url, '_blank', 'noopener,noreferrer');
```
The new tab now has a session cookie, so the `ensureLoggedIn` gate from the Express example lets it through. It's the same session a normal login would create; you're just creating it on demand.
> Don't put the credentials in the URL. A link like `https://user:pass@host/ui` is an easy one-click shortcut, but those credentials end up in the address bar, the browser history, and referrer headers. Use a cookie.
## Combine with read-only mode
Auth keeps strangers out. [Read-only mode](/bull-board/recipes/read-only-mode.md) keeps authenticated users from running destructive actions. Use both for public-facing status boards.
---
url: /bull-board/recipes/change-polling-interval.md
---
# Polling interval
The dashboard polls `GET /api/queues` to refresh. Default is 5 seconds. Two knobs in UIConfig.
## Let users pick
```ts
serverAdapter.setUIConfig({
pollingInterval: { showSetting: true },
});
```
Adds a picker in the settings modal. The browser persists the user's choice in localStorage.
## Force a specific interval and hide the picker
```ts
serverAdapter.setUIConfig({
pollingInterval: { forceInterval: 2000 }, // ms
});
```
Good for busy dashboards where you want to guarantee a floor, or slow ones where you want to cap load.
Don't go below 1 second. You'll hammer Redis without gaining anything visible.
---
url: /bull-board/recipes/csrf-protection.md
---
# CSRF protection
The dashboard's destructive actions (retry, clean, pause, obliterate) are state-changing PUT/POST calls. If the dashboard lives on the same origin as an untrusted user session, protect those calls with CSRF tokens.
From [`examples/with-express-csrf`](https://github.com/felixmosh/bull-board/tree/master/examples/with-express-csrf). The example uses [`csrf-csrf`](https://github.com/Psifi-Solutions/csrf-csrf) (double-submit cookie pattern). The older `csurf` package is deprecated, don't reach for it.
```js
const { doubleCsrf } = require('csrf-csrf');
const cookieParser = require('cookie-parser');
const { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: () => 'Secret',
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getTokenFromRequest: (req) => req.headers['x-xsrf-token'] || '',
cookieName: 'x-csrf-token',
cookieOptions: { secure: process.env.NODE_ENV === 'production' },
});
app.use(cookieParser());
app.get('/ui/*', (req, res, next) => {
if (['api', 'static'].every((part) => !req.path.includes(`/${part}/`))) {
const token = generateToken(req, res, true);
res.cookie('XSRF-TOKEN', token, {
sameSite: 'lax',
path: '/ui/',
secure: process.env.NODE_ENV === 'production',
});
}
next();
});
app.use('/ui', doubleCsrfProtection, serverAdapter.getRouter());
```
Two pieces are at play. `doubleCsrfProtection` rejects any non-GET request to `/ui` that lacks a valid token. The `XSRF-TOKEN` cookie is readable by the dashboard's bundled Axios, which mirrors it into the `x-xsrf-token` header on every request. That matches what `getTokenFromRequest` pulls off.
The extra `res.cookie` dance exists because the base cookie set by `csrf-csrf` is `httpOnly` (tracked in a pending upstream PR). Dashboard JS needs a readable copy, hence the second cookie.
If you're behind a login with `SameSite=Lax` session cookies, modern browsers already block most cross-site POSTs. Belt-and-suspenders CSRF on top doesn't hurt for high-value dashboards.
## See also
- [Basic auth](/bull-board/recipes/basic-auth.md). A login is usually a prerequisite before CSRF matters.
---
url: /bull-board/recipes/external-job-url.md
---
# Link jobs to your own admin
If your jobs exist in your own admin app too (an order, an email, a rendered report), you can link each dashboard job card to the corresponding page in your app.
Pass `externalJobUrl` to the queue adapter:
```ts
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
const adapter = new BullMQAdapter(ordersQueue, {
externalJobUrl: (job) => ({
displayText: `Order ${job.data.orderId}`,
href: `https://admin.example.com/orders/${job.data.orderId}`,
}),
});
```
The function receives the job's JSON representation and returns `{ href, displayText? }`. The dashboard adds a link next to the job name pointing at your URL.
Useful when debugging from the dashboard and you want to jump straight to the real-world record.
---
url: /bull-board/recipes/formatters.md
---
# Formatters
> Applies to: all adapters.
Formatters rewrite how a job's fields render in the dashboard without touching producer or worker code. Useful when `data`, `returnValue`, `name`, or `progress` need a presentation layer: masking secrets, summarising big payloads, humanising enums, prefixing names in multi-tenant setups.
## Register a formatter
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
const adapter = new BullMQAdapter(emailQueue);
adapter.setFormatter('data', (data) => ({
...data,
apiKey: data.apiKey ? '***' : undefined,
}));
createBullBoard({ queues: [adapter], serverAdapter });
```
`setFormatter` is per queue adapter. Each queue has its own set, only the fields you register are transformed.
## Available fields
| Field | Formatter receives | Must return | Notes |
| --------------- | --------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
| `'data'` | `job.data` | any JSON-serialisable value | Runs for every job. Good for redaction and summarisation. |
| `'returnValue'` | `job.returnvalue` | any JSON-serialisable value | Only meaningful on completed jobs. |
| `'name'` | the full `QueueJobJson` (`id`, `name`, `data`, `opts`, …) | `string` | Compose a display name from multiple fields. The raw job name stays at `jobProps.name`. |
| `'progress'` | `job.progress` (raw value) | any JSON-serialisable value | Typed as `string \| boolean \| number \| object`; return whatever the UI should render. |
## When formatters run
- On every job-list request and every job-detail request.
- Server-side, inside the API handler, before the response is sent.
- Not persisted. Your Redis data is untouched, only the response payload is rewritten.
## Performance
Formatters run once per job per request, the dashboard polls at a fixed interval, so the real cost is `cost per call × jobs per page × polls per minute`. Keep them cheap:
- No I/O, network, or DB lookups.
- Avoid large intermediate allocations, prefer in-place masking over deep clones.
- If the transform is expensive, cache the derived value on `data` at enqueue time instead.
## Source of truth
`setFormatter` and the `format` dispatch live on `BaseAdapter` in [`packages/api/src/queueAdapters/base.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/queueAdapters/base.ts) (lines 50–60). The `FormatterField` union is in [`packages/api/typings/app.d.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/typings/app.d.ts). Formatters are applied in [`packages/api/src/handlers/queues.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/handlers/queues.ts) (`formatJob`).
---
url: /bull-board/recipes/global-concurrency.md
---
# Global concurrency
BullMQ supports a global cap on concurrent jobs across all workers for a queue. The dashboard exposes it as a text input on the queue detail page.
## Set from the UI
Open the queue's settings dropdown, pick "Global concurrency", enter a number.
Bull-board calls `Queue.setGlobalConcurrency(n)` on your behalf. Workers respect the new cap on their next job pickup.
## Set in code
```ts
await queue.setGlobalConcurrency(5);
```
The value is stored in Redis so any worker across any process sees it.
Bull-only queues: global concurrency isn't supported. The UI hides the control.
## Read-only mode
Read-only mode disables the control. If you want stakeholders to see the number but not change it, `readOnlyMode: true` is the switch.
---
url: /bull-board/recipes/historical-metrics.md
---
# Historical metrics
> Applies to: BullMQ only.
>
> Beta: this feature ships in the opt-in `@bull-board/metrics` package. It is safe to run, but the API and Redis storage layout may still change in a minor release while it settles, so pin an exact version if you depend on the storage format.
bull-board is a viewer, not a monitor, and its built-in throughput chart reflects that: it reads BullMQ's native `queue.getMetrics()`, a per-minute ring buffer capped at `maxDataPoints`, scoped to a single queue, and only as deep as that buffer's window. Restart the buffer's window, or just wait long enough, and the older points are gone. There's no long history and no cross-queue total, because BullMQ was never asked to keep one.
`@bull-board/metrics` is an opt-in companion package that fills that gap. It doesn't replace the live chart, it adds a second, longer-retention path behind it: a recorder that snapshots the native metrics into Redis before they roll off, and a history provider you register with `createBullBoard` that lets the UI read them back.
## How it fits together
Two pieces, living in two different places.
`MetricsRecorder` runs in your own always-on process, typically wherever your workers already live. On an interval, it reads each queue's native completed/failed per-minute metrics and writes them into long-retention Redis buckets: a daily rollup per queue, plus a cross-queue global rollup. Writes are idempotent by minute, so it's safe to run the recorder in several processes, or restart it, without double-counting. There's no singleton to coordinate and no leader election.
`RedisMetricsHistoryProvider` runs wherever you build the board. You pass it to `createBullBoard({ options: { historyProvider } })`. The core itself only defines the `MetricsHistoryProvider` interface and stays stateless: registering a provider just turns on one additional read endpoint that delegates to it. `@bull-board/metrics` is the batteries-included Redis implementation, but if you already have a metrics store of your own, you can implement the interface directly instead of adopting this package. With no provider configured, nothing about the board changes.
## Precondition: native metrics must be on
The recorder can only snapshot what BullMQ is already collecting, so your Workers need native metrics enabled with a window wide enough to survive any recorder downtime:
```ts
import { Worker, MetricsTime } from 'bullmq';
const worker = new Worker(name, processor, {
connection,
metrics: { maxDataPoints: MetricsTime.ONE_WEEK },
});
```
If metrics aren't enabled on the workers, `queue.getMetrics()` returns nothing, and the recorder has nothing to snapshot. Registering the `historyProvider` still turns the history UI on, but with no snapshots behind it the charts simply render empty, the same way the live metrics view does when metrics are off. A week-long window gives the recorder plenty of slack to catch up after a deploy or an outage before any minute falls out of the ring buffer unrecorded.
## Job latency
Alongside the completed/failed counters, the recorder also tracks two histograms per queue: wait time and run time. Wait time is `processedOn - timestamp`, how long a job sat before a worker picked it up, and it's the signal that says you need more workers. Run time is `finishedOn - processedOn`, how long the handler itself took, and it's the signal that says the handler regressed. They're kept as two separate numbers rather than one combined figure because they point at different fixes: a wait spike means scale out, a run spike means look at the handler.
Collecting them needed no changes to your workers. BullMQ's `moveToFinished` does `ZADD targetSet, timestamp, jobId` when a job completes or fails, so the completed and failed sets are sorted sets scored by finish time. On the same tick that snapshots the counter metrics, the recorder scans each finished set past a watermark it keeps per queue, and that scan returns exactly the jobs that finished since the last tick, no more and no less, so there are no gaps and nothing is double-counted. Because it reads job hashes and sorted sets BullMQ already writes, latency sampling doesn't depend on `queue.getMetrics()` or the `metrics` worker option at all; the precondition above is only for the counter metrics.
The most important thing to understand about the wait histogram is what it can't see. It's built entirely from jobs that finished, so when a queue is genuinely backed up and jobs stop finishing, the histogram goes quiet exactly when the number matters most: a stalled queue looks identical to an idle one. That's why the recorder also records a queue-age gauge, the age of the oldest job still waiting, and the UI overlays it on the same axis as the wait chart. It keeps reporting when the histogram can't, because it doesn't depend on anything finishing. It's the same reason Sidekiq's `Queue#latency` measures how long the oldest job has been waiting rather than the latency of jobs it has already run.
Retries are excluded from the wait histogram, but not the run histogram. A job's `timestamp` field is set once, at creation, but `processedOn` is overwritten on every attempt, so a retried job's naive wait time would absorb every prior attempt and all the backoff between them. Run time has no such problem: every attempt gets its own sample, retried or not.
Percentiles read off these histograms are estimates, not exact values, bounded by the width of the bucket a sample landed in. The bucket layout is fixed and not something you can configure per queue, because two ranges holding different bucket layouts can't be merged into one percentile: a multi-day query has to combine buckets from different days, and that only works if every day used the same layout.
If a queue is configured with `removeOnComplete: true`, jobs are deleted the instant they finish, so there is nothing left in the completed set for the next tick to scan, and no latency data will ever appear for it. There's no error and no warning, just an empty chart. If latency data isn't showing up for a queue, this is the first thing to check.
Latency shares the chart area with throughput behind a tab, on a queue's own page and on the Metrics history page alike, so you get one chart at a time rather than a stack of them. The totals above the chart follow the tab: completed and failed counts on Throughput, p95 run and p95 wait for the selected range on Latency. Wait time carries the queue-age gauge as a dashed line on the same axis, since both are durations:

Run p95, wait p95 and queue age are drawn by default; p50 and p99 for each are a click away in the legend, and what you enable is remembered. The axis is logarithmic, and labelled as such. Latency spans orders of magnitude, so on a linear axis a p99 measured in minutes flattens p50 and p95 into a single line along the bottom and the chart stops saying anything about the typical case.
The newest point on any chart is drawn dashed while its bucket is still filling, because today's day, or the current hour, only covers the time elapsed so far and would otherwise read as a sudden drop.
Latency sampling is on by default whenever the recorder runs. Set `latency: false` to turn it off:
```ts
const recorder = new MetricsRecorder({
queues: [new BullMQAdapter(myQueue)],
connection: redisOptions,
latency: false, // optional, default true
});
```
A latency tick that fails is swallowed rather than propagated, so a broken scan can't take the counter snapshot down with it, which also means a collector that has been failing since startup looks exactly like a board with no traffic. Pass `onLatencyError: (error, queueName) => log(error)` to tell the two apart; it stays silent if you don't.
## Install
```bash
yarn add @bull-board/metrics
```
`ioredis` is a peer dependency; you already have it if you're using BullMQ.
## Set up the recorder
In the process where your workers run:
```ts
import { MetricsRecorder } from '@bull-board/metrics';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
const recorder = new MetricsRecorder({
queues: [new BullMQAdapter(myQueue)],
connection: redisOptions, // same ioredis connection options your app uses
retentionDays: 90, // optional, default 90
snapshotIntervalMs: 60_000, // optional, default 60s
});
recorder.start();
```
Retention is enforced by Redis itself, so old buckets expire on their own and there's nothing to prune by hand. Buckets are UTC-aligned, and every key the recorder writes is namespaced under `bull-board:metrics:`, so it can't collide with BullMQ's own keys.
On shutdown, call `recorder.stop()`. It clears the snapshot interval and, if the recorder created its own Redis connection internally, closes it too. If you passed in your own `Redis` instance, `stop()` leaves that connection alone, so it's a safe no-op to call either way.
## Storage footprint
Worth understanding before you turn this on, since it writes to the same Redis your queues run on.
### Three resolutions, three retentions
Every snapshot is written at three resolutions at once: per-minute buckets, an hourly rollup, and a daily total. They cost wildly different amounts, so each has its own retention:
| Tier | What it holds | Default retention | Size per busy day, per queue and metric |
| ------ | --------------------------------------------- | ----------------- | --------------------------------------- |
| Minute | One entry per minute that had activity | 7 days | \~72 KB |
| Hour | 24 entries per day | 90 days | \~0.3 KB |
| Day | One entry per day, in a single hash per queue | 90 days | \~15 bytes |
Those figures are measured, not estimated: `packages/metrics/tests/footprint.spec.ts` writes real data through the real code path and asserts them. Absolute numbers shift a little with your Redis version and `hash-max-listpack-entries`, so the test pins the ratios rather than exact bytes and prints what it measured.
The minute tier is roughly 240 times more expensive than the hourly rollup covering the same day. That single ratio is what the whole design turns on.
### What that adds up to
At the defaults, tracking both completed and failed:
| Scenario | Per queue |
| ------------------------------------------ | --------- |
| Busy every minute, around the clock | \~1.1 MB |
| Bursty, active roughly a tenth of the time | \~120 KB |
| Idle | \~0 |
Plus the cross-queue rollup, which costs about as much as one queue running at the combined rate, once, no matter how many queues you register.
Idle time is free. A minute with a zero count is never written, so the footprint follows how busy a queue actually is, not how long it has been recording.
### Why the rollups exist
Keeping 90 days of minute-level detail costs about 15 MB per queue. Keeping 90 days of _hourly_ detail costs about 55 KB, and the daily charts the UI actually draws don't read either one, they read the daily totals.
So rather than storing one expensive resolution and throwing detail away later, the recorder writes all three as it goes. Every tier is derived from the same delta in the same atomic script, so they can't drift apart, and there is no compaction job to schedule, resume, or make idempotent after a crash.
That's what lets the minute window default to 7 days instead of 90, which is where the \~93% saving comes from. Nothing you can see in the UI changes.
### Tuning it
```ts
const recorder = new MetricsRecorder({
queues: [new BullMQAdapter(myQueue)],
connection: redisOptions,
retention: {
minutes: 7, // days of minute-level detail
hours: 90, // days of hourly rollup
days: 90, // days of daily totals
},
});
```
Every tier is optional and falls back to the default. Pass the same `retention` to `RedisMetricsHistoryProvider` so reads use the same window.
The minute window is the one to think about, for two reasons. It is where essentially all the storage goes, and it doubles as the recorder's catch-up window: after downtime, the recorder will not backfill minutes older than it. Set it to at least as long as you'd want to recover from an outage, and no longer than your workers' `maxDataPoints` buffer can supply anyway. The default of 7 days lines up with the recommended `MetricsTime.ONE_WEEK`.
Cutting `minutes` to 1 takes a busy queue from \~1.1 MB to \~200 KB, at the cost of only being able to catch up on a day of downtime. Hourly and daily history are unaffected either way.
`retentionDays: N` still works as a shorthand. It sets the hourly and daily windows to `N` and leaves the minute window at its default, so an existing config can't accidentally ask for a year of minute-level detail.
### Nothing grows without a ceiling
Day-scoped keys carry a TTL that is only refreshed while that day is being written, so each one dies its tier's retention after the day it covers. The daily totals hashes are written every day, so their TTL keeps rolling forward; they're trimmed instead, dropping entries that fall outside the window on the first write of each new day. Both paths are covered by tests.
Upgrading from an earlier version is safe: days recorded before the hourly tier existed are still served, by folding their minute buckets on read.
### What latency costs
The figures above are for the completed/failed counters. Latency histograms and the queue-age gauge use a different, packed storage format, so they're measured separately against a real Redis rather than derived from the same arithmetic: a queue where most jobs land in a couple of buckets each hour costs a lot less than one where all 18 buckets fill up every hour, and only a real measurement across both shapes tells you which end of that range to expect.
| Scenario | Measured |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| One queue, 90 day retention, both histograms plus the queue-age gauge, realistic concentrated distribution | 254.5 KB |
| Same, pathological: all 18 buckets populated heavily every hour | 574.6 KB |
| Shared `__global__` cross-queue rollup | \~224 KB once, for the whole board |
| `sample()` wall time at 1000 finished jobs in one tick | 9.11 ms |
| Redis round trips per queue per tick | \~12, flat in job count |
| Subsampling cap holds tick time bounded | 5000 jobs: 30.39 ms, 10000 jobs: 29.94 ms |
The line that decides whether this is affordable on a large board is the global rollup: it's a single shared cost for the whole board, not multiplied per queue, because there is exactly one `__global__` key no matter how many queues are registered. 200 queues at typical traffic is roughly 200 × 254.5 KB, about 50 MB, plus the one shared 224 KB rollup, not 200 copies of it.
The round-trip count staying flat, and tick time barely moving between 5000 and 10000 jobs, come from the same design decision: above `maxSamplesPerTick`, the sampler takes a uniform subset of the finished jobs and scales the counts back up, rather than fetching every job, so a tick against a queue processing thousands of jobs a minute costs about the same as one processing hundreds.
## Inspecting and clearing history from the board
When the configured provider supports it (the shipped `RedisMetricsHistoryProvider` does), a **Storage** entry appears in the actions menu on the Metrics history page, next to the range selector. It's tucked into the menu because it's an occasional maintenance task rather than something you'd read day to day. Usage is only fetched when you open it, since measuring real memory use means reading every history key.

It shows the total footprint broken down by tier, so an unexpectedly large minute tier is visible at a glance, along with a per-queue table and the range of days on record.

Two actions sit underneath it:
- **Keep only the last 7d / 30d / 90d** deletes everything recorded before the range you're currently charting.
- **Clear all history** deletes everything, for every queue.
Both open a confirmation that spells out exactly what goes: the cutoff date or the total size, that it can't be undone, and that the deleted days will stop appearing in the charts. Recording carries on either way, so new data starts accumulating from the next snapshot.

The actions are hidden when every registered queue is in `readOnlyMode`, matching how the rest of the board treats destructive operations. If your provider implements `getUsage` but not `purge`, the modal renders read-only.
One caveat on per-queue purges: the cross-queue rollup is corrected by subtracting the queue's own recorded values, so it needs those keys to still exist. A queue whose keys were already removed out of band, or whose minute hashes have aged out, can leave a residue in the rollup that a per-queue purge can't reach. Clearing all history resets it.
## Inspecting and clearing history from code
`MetricsHistoryAdmin` is the same maintenance surface as a plain library object, for a debug endpoint, a one-off script, or a cleanup job.
```ts
import { MetricsHistoryAdmin } from '@bull-board/metrics';
const admin = new MetricsHistoryAdmin({ connection: redisOptions });
const stats = await admin.stats();
// {
// keys: 182, bytes: 1543210, minutes: 12480,
// oldestDay: '2026-04-23', newestDay: '2026-07-22',
// tiers: { minute: { keys: 14, bytes: 1400000 }, hour: {...}, day: {...} },
// queues: [{ queue: 'mailer', keys: 91, bytes: 900123, minutes: 7200, tiers: {...} }, ...]
// }
```
`stats()` reports the real Redis footprint (`MEMORY USAGE` per key), split by tier and by queue, largest first, with the cross-queue rollup listed as `__global__`. Measurements go out in pipelined batches, so a namespace of a few thousand keys costs a couple of dozen round trips rather than a few thousand. It still reads every history key though, so treat it as an ops call rather than something to poll.
`purge()` deletes stored history. It's scoped to the `bull-board:metrics:` namespace and driven by `SCAN`, so it never blocks Redis and never touches your queues' own keys:
```ts
await admin.purge(); // everything
await admin.purge({ queue: 'mailer' }); // one queue
await admin.purge({ before: '2026-06-01' }); // anything older than a day
await admin.purge({ queue: 'mailer', before: new Date('2026-06-01') });
```
Purging a single queue also subtracts that queue's numbers from the cross-queue rollup, so the Metrics history page reflects the queues that are left instead of keeping a deleted queue's throughput folded into the total. Purging is idempotent and safe to repeat, and the recorder simply starts refilling from the next snapshot.
Call `admin.disconnect()` when you're done. Like the recorder and the provider, it only closes the Redis connection if it opened one itself.
## Register the provider
Where you build the board. The per-queue chart on each queue page needs `showMetrics: true` in `uiConfig` as well (see [UIConfig](/bull-board/configuration/ui-config.md)); the dedicated "Metrics history" page below doesn't need it, but you'll usually want both:
```ts
import { createBullBoard } from '@bull-board/api';
import { RedisMetricsHistoryProvider } from '@bull-board/metrics';
createBullBoard({
queues,
serverAdapter,
options: {
uiConfig: { showMetrics: true },
historyProvider: new RedisMetricsHistoryProvider({ connection: redisOptions }),
},
});
```
Call `provider.disconnect()` alongside `recorder.stop()` on shutdown. Same rule applies: it only closes the Redis connection if the provider opened it itself.
## What changes in the UI
Once a `historyProvider` is configured, `hasHistoryProvider` flips on and things show up in two places, gated by two separate settings.
With `showMetrics: true`, each queue's metrics chart gains a range selector: 60m, 7d, 30d, 90d. 60m stays exactly what it was, the live native view straight off the ring buffer. The longer ranges switch to reading from the history provider instead. Without `showMetrics: true`, the per-queue chart doesn't render at all, range selector included, regardless of whether a `historyProvider` is set. The chart can also be collapsed with the chevron in its header; the collapsed state is remembered.

A "Metrics history" page also appears in the sidebar, independent of `showMetrics`. It's a cross-queue view, the closest thing bull-board has to a wallboard: total completed/failed throughput across every registered queue, over the same range selector, plus a per-queue breakdown table underneath, sorted by total runs.
Each row in that table carries a bar scaled against the busiest queue and split into a completed and a failed segment. Bar length compares volume between queues, the split compares outcomes inside one queue, and hovering a bar gives the exact counts. A queue that only fails a fraction of a percent of its runs would otherwise draw a segment too thin to see, so a non-zero segment never shrinks below 1% of the track. The failure rate is spelled out next to the failed count.

Leave `historyProvider` unset and none of this appears; the board behaves exactly as it did before.
## Scope
This is BullMQ only. Bull v3 has no native metrics to snapshot. Completed and failed throughput, wait time, run time, and queue age are tracked; there's no history for other job states or for job data itself.
The shipped UI reads daily rollups. The provider also supports hourly granularity through the `/api/metrics/history` endpoint (`granularity: 'hour'`) for custom consumers, though the built-in charts and the Metrics history page don't use it.
---
url: /bull-board/recipes/index.md
---
# Recipes
Most recipes link into the live demo so you can see the shape before porting it.
Short, code-first walkthroughs for common setups. Each recipe is a page; each page ties back to a runnable example in the repo.
## Recipes
| Task | Recipe | Adapters shown |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------ |
| Protect the dashboard with basic auth | [Add basic auth](/bull-board/recipes/basic-auth.md) | Express, Fastify, Hapi, NestJS |
| Defend against CSRF on destructive actions | [CSRF protection](/bull-board/recipes/csrf-protection.md) | Express |
| Run several dashboards in one app | [Multiple dashboards](/bull-board/recipes/multiple-dashboards.md) | Express |
| Add or remove queues after startup | [Manage queues at runtime](/bull-board/recipes/manage-queues-at-runtime.md) | All |
| Show only a tenant's queues per request | [Per-tenant visibility](/bull-board/recipes/per-tenant-visibility.md) | Fastify |
| Surface worker logs and job flows in the UI | [Job logs and flows](/bull-board/recipes/job-logs-and-flows.md) | All |
| Get notified when jobs fail | [Alerting on failed jobs](/bull-board/recipes/alerting.md) | All |
| Change or force the polling interval | [Polling interval](/bull-board/recipes/change-polling-interval.md) | All |
| Link jobs to your own admin pages | [External job URLs](/bull-board/recipes/external-job-url.md) | All |
| Set global concurrency from the UI | [Global concurrency](/bull-board/recipes/global-concurrency.md) | All |
| Keep long-retention throughput history beyond BullMQ's ring buffer | [Historical metrics](/bull-board/recipes/historical-metrics.md) | BullMQ |
| Deploy the dashboard on Next.js / Vercel | [Next.js & Vercel](/bull-board/recipes/nextjs.md) | Hono, Express |
| Diagnose a dashboard that won't load | [Troubleshooting](/bull-board/recipes/troubleshooting.md) | All |
Missing something? Open an issue. felixmosh is responsive, and good recipes become features.
---
url: /bull-board/recipes/job-logs-and-flows.md
---
# Job logs and flows
Two features people often miss.
## Job logs
In a BullMQ worker, call `job.log()` to push lines that appear in the dashboard's job detail view:
```ts
import { Worker } from 'bullmq';
new Worker('emails', async (job) => {
await job.log(`Sending to ${job.data.to}`);
await sendEmail(job.data);
await job.log('Sent.');
}, { connection });
```
Open the job in the dashboard, switch to the Logs tab.

Live example: open the demo and drill into a worker-processed job in `emails:welcome`.
## Job flows
BullMQ supports flows. Parent jobs that wait on child jobs across queues. Build one with `FlowProducer`:
```ts
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection });
await flow.add({
name: 'build-report',
queueName: 'reports',
children: [
{ name: 'fetch-data', queueName: 'fetch' },
{ name: 'render-pdf', queueName: 'render' },
],
});
```
Bull-board renders the tree on the parent job's detail view. Click through to jump between parents and children.

Live example: open the demo and scroll to `reports:nightly` for a parent job with children.
Caveat: `job.log()` is BullMQ-only. Bull has no equivalent.
---
url: /bull-board/recipes/manage-queues-at-runtime.md
---
# Add and remove queues at runtime
> Applies to: all adapters.
`createBullBoard` isn't a one-shot call. It also returns four functions that change which queues the board shows while it's running, with no rebuild or restart:
```ts
const { addQueue, removeQueue, setQueues, replaceQueues } = createBullBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
});
```
Use them when queues aren't all known at startup: per-tenant queues created on demand, queues discovered from a registry, or a dashboard that follows queues as workers spin them up and down.
## The four functions
| Function | Signature | Effect |
| --------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- |
| `addQueue` | `(queue: BaseAdapter) => void` | Adds one queue. Overwrites if a queue with the same name is already registered. |
| `removeQueue` | `(queueOrName: string \| BaseAdapter) => void` | Removes one queue, by adapter or by name. No-op if it isn't registered. |
| `setQueues` | `(queues: BaseAdapter[]) => void` | Adds or overwrites a batch, and **leaves other registered queues in place**. |
| `replaceQueues` | `(queues: BaseAdapter[]) => void` | Same as `setQueues`, but also **removes any queue not in the list**. A full sync. |
Queues are keyed by name (`queue.getName()`, which includes any `prefix` you set). Registering two adapters that resolve to the same name means the second wins.
## Add a queue on demand
```ts
const board = createBullBoard({ queues: [], serverAdapter });
function onTenantCreated(tenantId: string) {
const queue = new Queue(`emails-${tenantId}`, { connection });
board.addQueue(new BullMQAdapter(queue));
}
function onTenantDeleted(tenantId: string) {
board.removeQueue(`emails-${tenantId}`);
}
```
The dashboard picks up the change on its next poll, with no reload or remount.
## Sync the board to a known set
`setQueues` vs `replaceQueues` differ only in what happens to queues you _don't_ pass:
```ts
// Board currently shows: A, B, C
board.setQueues([b, d]); // → A, B, C, D (adds D, updates B, keeps A and C)
board.replaceQueues([b, d]); // → B, D (drops A and C)
```
Reach for `replaceQueues` when you have the authoritative full list and want the board to mirror it exactly. Reach for `setQueues` when you're merging in a batch and don't want to disturb queues registered elsewhere.
## Notes
- Changes take effect on the next request. The functions write to the same `Map` the board reads each time, so there's no cache to bust.
- Removing a queue only detaches it from the dashboard. It doesn't close the Bull/BullMQ connection or touch Redis, so clean those up yourself if the queue is really gone.
- On [NestJS](/bull-board/server-adapters/nestjs.md) you don't hold the return value of `createBullBoard` directly. The module already calls `addQueue` when you register feature queues, and it exposes the same board instance for manual changes via `@InjectBullBoard() board: BullBoardInstance`. See [`examples/with-nestjs-module`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nestjs-module).
## Source of truth
The four functions are built in [`packages/api/src/queuesApi.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/queuesApi.ts) and returned from `createBullBoard` in [`packages/api/src/index.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/index.ts).
---
url: /bull-board/recipes/multiple-dashboards.md
---
# Multiple dashboards in one app
You might want separate dashboards for different queue groups. One per team, or one read-only and one read-write.
From [`examples/with-multiple-instances`](https://github.com/felixmosh/bull-board/tree/master/examples/with-multiple-instances).
```js
const serverAdapter1 = new ExpressAdapter();
const serverAdapter2 = new ExpressAdapter();
createBullBoard({
queues: [new BullMQAdapter(queueA)],
serverAdapter: serverAdapter1,
});
createBullBoard({
queues: [new BullMQAdapter(queueB)],
serverAdapter: serverAdapter2,
});
serverAdapter1.setBasePath('/instance1');
serverAdapter2.setBasePath('/instance2');
app.use('/instance1', serverAdapter1.getRouter());
app.use('/instance2', serverAdapter2.getRouter());
```
Each adapter is independent. Pass a different UIConfig per instance (`boardTitle`, `boardLogo`, `environment` badge) to make them visually distinct.
## When to use this instead of a visibility guard
- Different auth strategies per dashboard. Use multiple dashboards.
- Same auth, per-tenant queue visibility. Use [per-tenant visibility](/bull-board/recipes/per-tenant-visibility.md) instead.
- Different `readOnlyMode` policies per audience. Multiple dashboards, each with different queue-adapter options.
---
url: /bull-board/recipes/nextjs.md
---
# Next.js & Vercel
There is no dedicated Next.js adapter — bull-board runs inside a Next.js API
route using an existing adapter. Two runnable examples:
- [`examples/with-nextjs-app`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nextjs-app) — App Router, `@bull-board/hono` adapter.
- [`examples/with-nextjs-pages`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nextjs-pages) — Pages Router, `@bull-board/express` adapter.
Both deploy to Vercel. The mounting differs by router; the Vercel-specific
config is identical and is the part most people miss.
## App Router (Hono)
A single optional catch-all Route Handler at
`app/api/queues/[[...path]]/route.ts`:
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { handle } from 'hono/vercel';
import { queue } from '@/lib/queue';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const basePath = '/api/queues';
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath(basePath);
createBullBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
const app = new Hono();
app.route(basePath, serverAdapter.registerPlugin());
export const GET = handle(app);
export const POST = handle(app);
export const PUT = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
```
## Pages Router (Express)
A single optional catch-all API route at `pages/api/queues/[[...path]].ts` that
delegates to an Express router. Disable `bodyParser` and enable
`externalResolver` so Express owns the response:
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import express from 'express';
import { queue } from '../../../lib/queue';
const basePath = '/api/queues';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath(basePath);
createBullBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
const app = express();
app.use(basePath, serverAdapter.getRouter());
export const config = { api: { bodyParser: false, externalResolver: true } };
export default function handler(req, res) {
return app(req, res);
}
```
## The Vercel fix (both routers)
`@bull-board/api` finds the UI's compiled assets with
`eval(require.resolve('@bull-board/ui/package.json'))`. The `eval` deliberately
hides the require from bundlers — and that includes Next.js's static file tracer
([`@vercel/nft`](https://github.com/vercel/nft)). On Vercel the UI files are
never copied into the function, so you get:
```
Error: Cannot find module '@bull-board/ui/package.json'
```
Fix it in `next.config.js`:
```js
/** @type {import('next').NextConfig} */
module.exports = {
// Resolve bull-board and bullmq from node_modules at runtime, not from the bundle.
serverExternalPackages: ['@bull-board/api', '@bull-board/ui', 'bullmq'],
// Force the compiled UI into the serverless function (the tracer can't see the eval).
outputFileTracingIncludes: {
'/api/queues/*': ['./node_modules/@bull-board/ui/dist/**/*'],
},
};
```
::: warning Monorepo
If `node_modules` is hoisted to a workspace root (e.g. `apps/web` in a Turborepo),
add `outputFileTracingRoot: path.join(__dirname, '../../')` so the included paths
resolve from the right place.
:::
`serverExternalPackages` and `outputFileTracingIncludes` are stable top-level
options in **Next.js 15+** (in 13/14 they lived under `experimental`).
### Alternative: `uiBasePath`
Instead of the trace config you can tell bull-board where the UI lives directly,
skipping the `eval(require.resolve(...))` entirely:
```ts
createBullBoard({
queues,
serverAdapter,
options: { uiBasePath: 'node_modules/@bull-board/ui' },
});
```
You still need `outputFileTracingIncludes` so the files are actually deployed —
this only changes how the path is resolved, not whether the files are present.
## Workers
BullMQ workers are long-running and **cannot** run inside serverless functions.
Next.js (the dashboard and any job-producing routes) deploys to Vercel; the
worker runs as a separate always-on process — a container, a VM, or a dedicated
worker service. Both examples ship a standalone `worker.ts` for local
processing.
---
url: /bull-board/recipes/per-tenant-visibility.md
---
# Per-tenant visibility
Show each user only the queues they're allowed to see. One shared dashboard, per-request filtering.
See also: [Visibility guard](/bull-board/recipes/visibility-guard.md) for the full reference.
From [`examples/with-fastify-visibility-guard`](https://github.com/felixmosh/bull-board/tree/master/examples/with-fastify-visibility-guard) (Fastify + cookie auth + JWT).
## How the Fastify example wires it
The full example issues a signed JWT on login with an `allowedQueues` array, then the guard decodes the cookie and matches it against the queue name:
```js
function visibilityGuard(req) {
const cookies = fastify.parseCookie(req.headers.cookie || '');
if (!cookies.token) return false;
try {
const decoded = fastify.jwt.verify(cookies.token);
return decoded.allowedQueues?.includes(this.queue.name) ?? false;
} catch {
return false;
}
}
createBullBoard({
queues: queues.map((queue) => {
const adapter = new BullMQAdapter(queue);
adapter.setVisibilityGuard(visibilityGuard);
return adapter;
}),
serverAdapter,
});
```
`this.queue.name` inside the guard gives you the queue the guard is attached to, so one function handles every queue.
## Minimal Express sketch
Same idea, simpler auth:
```ts
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
const tenantAQueue = new BullMQAdapter(queueA);
tenantAQueue.setVisibilityGuard((req) => req.headers['x-tenant-id'] === 'tenant-a');
const tenantBQueue = new BullMQAdapter(queueB);
tenantBQueue.setVisibilityGuard((req) => req.headers['x-tenant-id'] === 'tenant-b');
const sharedQueue = new BullMQAdapter(common);
sharedQueue.setVisibilityGuard(() => true);
createBullBoard({
queues: [tenantAQueue, tenantBQueue, sharedQueue],
serverAdapter,
});
```
Every API call passes through the guard. A hidden queue is invisible in the sidebar, absent from counts, and returns 404 on direct access.
## Hot-path warning
The guard runs once per visible queue per request, and the UI polls. Don't do synchronous HTTP or DB calls inside it. Decode a JWT, pull a tenant ID from a cookie, check a map. That's the budget.
---
url: /bull-board/recipes/read-only-mode.md
---
# Read-only mode
> Applies to: all adapters.
Read-only mode disables every destructive action on a queue. No retries, no removals, no queue operations (pause, resume, empty, clean, obliterate), no adding jobs. Use it to share the dashboard with stakeholders, support, or shared dev environments without risking anything.
## Enable per queue
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
createBullBoard({
queues: [
new BullMQAdapter(emailQueue, { readOnlyMode: true }),
],
serverAdapter,
});
```
The flag is per queue adapter, so one board can mix read-only and writable queues.
## What gets disabled
With `readOnlyMode: true`, the API endpoint for any write action on that queue returns **HTTP 405 Method Not Allowed**, and the matching buttons are hidden in the UI.
| Action | Disabled in read-only mode |
| ----------------------------------------- | ----------------------------------------------------- |
| Add job | Yes |
| Retry job (single) | Yes |
| Retry all | Yes |
| Promote job (single) | Yes |
| Promote all | Yes |
| Remove job (single) | Yes |
| Clean bulk (completed / failed / waiting) | Yes |
| Empty queue | Yes |
| Obliterate queue | Yes |
| Pause / resume queue | Yes |
| Set global concurrency | Yes |
| Update job data | Yes |
| View queue, job, logs, flow | No, still accessible |
| Pause-all / resume-all | Read-only queues are silently skipped, others proceed |
## Disabling retries only
`allowRetries` is independent. On a **writable** queue, `allowRetries: false` hides the retry buttons in the UI while leaving every other action in place:
```ts
new BullMQAdapter(emailQueue, { allowRetries: false });
```
Defaults to `true` on writable queues. When `readOnlyMode: true`, `allowRetries` is forced to `false`, the option is ignored since retries are themselves a destructive action.
To keep failed-job retries but hide the retry button on **completed** jobs, use `allowCompletedRetries: false` (BullMQ only):
```ts
new BullMQAdapter(emailQueue, { allowCompletedRetries: false });
```
It only takes effect while `allowRetries` is `true`. On `BullAdapter` it's always off, because Bull can't retry completed jobs.
::: warning
`allowRetries: false` only hides the retry buttons — it doesn't block the retry API endpoint. Anyone who knows the URL can still trigger a retry. Use `readOnlyMode: true` for real enforcement.
:::
## Source of truth
See `QueueAdapterOptions` in [`packages/api/typings/app.d.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/typings/app.d.ts), the flag resolution in [`packages/api/src/queueAdapters/base.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/queueAdapters/base.ts), and the 405 enforcement in [`packages/api/src/providers/queue.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/providers/queue.ts).
---
url: /bull-board/recipes/troubleshooting.md
---
# Troubleshooting
> Applies to: all adapters.
The failure modes below account for most "it won't load" reports. Nearly all of them come down to one thing: the path bull-board thinks it's mounted at doesn't match the path the browser actually requests.
## The page loads but assets and API calls 404
You see the HTML, but the styles are missing and the network tab is full of 404s for `/static/...` and `/api/...`.
`setBasePath` and the path you mount the router at have to be the same string. bull-board bakes the base path into the HTML it serves, and the browser builds every asset and API URL from it. If they disagree, every follow-up request misses.
```ts
serverAdapter.setBasePath('/admin/queues'); // ← these two
app.use('/admin/queues', serverAdapter.getRouter()); // ← must match
```
Change one, change the other.
## Same 404s, but only behind a reverse proxy
Works on `localhost`, breaks once it's behind nginx / a load balancer / an ingress at a subpath.
The base path has to be the path the **browser** sees, not the internal one. If the proxy exposes the dashboard at `https://example.com/tools/queues` but forwards to your app at `/`, then `setBasePath('/tools/queues')`, the public path, and let the proxy route it. The rule is the same as above; the "mount path" is just whatever the outside world requests.
If the proxy strips the prefix before forwarding, either stop stripping it or set the base path to the stripped value, whichever keeps the browser's URL and the base path in agreement.
## `Cannot find module '@bull-board/ui/package.json'`
Thrown at startup, almost always under a bundler (Next.js/Vercel, esbuild, `ncc`, a Docker build that prunes `node_modules`).
bull-board locates the compiled UI with `eval(require.resolve('@bull-board/ui/package.json'))`. The `eval` is deliberate: it hides the require from bundlers so they don't try to inline the whole UI, but it also means bundlers don't know to ship those files. Two fixes: point bull-board at the UI directly with `options.uiBasePath`, and/or tell the bundler to include the files. The [Next.js & Vercel recipe](/bull-board/recipes/nextjs.md) walks through both.
## Blank page, or a 500 on the dashboard root
If the response is a 500 rather than a 404, it's usually the missing-UI case above (the view can't render). A genuinely blank page with no failed requests is more often a strict **Content-Security-Policy** on the parent app blocking the dashboard's scripts or styles. Check the browser console for CSP violations and allow bull-board's `/static` origin if so.
## Buttons do nothing, or actions error after an upgrade
If retry/clean/pause started failing after you bumped a dependency, suspect a Bull / BullMQ version mismatch between your workers and the version bull-board resolves. Pin the same version across both. See [version pinning](/bull-board/configuration/production-checklist.md#version-pinning).
## A destructive action returns 405
That's [read-only mode](/bull-board/recipes/read-only-mode.md) doing its job. The queue was registered with `readOnlyMode: true` (or the action is gated by `allowRetries`). Intended, not a bug.
## Still stuck
Open an issue on [felixmosh/bull-board](https://github.com/felixmosh/bull-board/issues) with your adapter, versions, and the mount/base-path setup. Most reports resolve to one of the above once the exact paths are on the table.
---
url: /bull-board/recipes/visibility-guard.md
---
# Visibility guard
> Applies to: all adapters.
A visibility guard is a per-request predicate on a queue adapter that decides whether the requester can see that queue. Use it for multi-tenant setups, or for role-based access on a shared dashboard.
## Shape
`setVisibilityGuard` lives on `BaseAdapter`, every queue adapter (Bull, BullMQ) inherits it:
```ts
queueAdapter.setVisibilityGuard(
(request: BullBoardRequest) => boolean | Promise,
);
```
`BullBoardRequest` carries the fields bull-board pulled from the underlying server's request: `queues`, `uiConfig`, `query`, `params`, `body`, `headers`. Authenticate off `request.headers` (cookies, bearer tokens) and route on `request.params.queueName` or a reference captured in the closure.
## Register the guard
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
const billingAdapter = new BullMQAdapter(billingQueue);
billingAdapter.setVisibilityGuard((request) => {
const user = decodeUserFromHeaders(request.headers);
return user?.roles.includes('billing') ?? false;
});
const notificationsAdapter = new BullMQAdapter(notificationsQueue);
notificationsAdapter.setVisibilityGuard((request) => {
const user = decodeUserFromHeaders(request.headers);
return !!user; // visible to any authenticated user
});
createBullBoard({
queues: [billingAdapter, notificationsAdapter],
serverAdapter,
});
```
Each queue has its own guard. Queues without a guard are visible to everyone.
## How it runs
- Invoked on every queue list request and every per-queue API call.
- Runs on the hot path. The dashboard polls on an interval, so every queue's guard runs every poll cycle.
- Async is allowed (`Promise`), but I/O inside the guard will serialise requests. Read a pre-validated session off headers, or use a small in-memory cache, rather than hitting the DB on every poll.
- Guards run after your framework's auth layer. Reject unauthenticated requests before bull-board's router, the guard should assume "is the requester authenticated?" is already decided.
## Hidden means hidden
A queue that fails its guard is fully invisible to that request:
- It does not appear in the sidebar or the overview.
- It is not counted in aggregate metrics.
- Every per-queue API endpoint (jobs, logs, flow, retry, pause, …) returns **HTTP 404 Queue not found**.
- Queue-wide actions like pause-all and resume-all silently skip it.
No "locked" state. The UI behaves as if the queue doesn't exist.
## Full runnable example
- [`examples/with-fastify-visibility-guard`](https://github.com/felixmosh/bull-board/tree/master/examples/with-fastify-visibility-guard): cookie-based auth with two users, each limited to a different queue.
## Source of truth
`setVisibilityGuard` and `isVisible` are on `BaseAdapter` in [`packages/api/src/queueAdapters/base.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/queueAdapters/base.ts) (lines 62–68). Enforcement is in [`packages/api/src/providers/queue.ts`](https://github.com/felixmosh/bull-board/blob/master/packages/api/src/providers/queue.ts) and in the list handlers under [`packages/api/src/handlers/`](https://github.com/felixmosh/bull-board/tree/master/packages/api/src/handlers).
---
url: /bull-board/server-adapters/bun.md
---
# Bun
[Bun](https://bun.sh/). `@bull-board/bun` targets Bun's native HTTP server.
## Install
```sh
bun add @bull-board/api @bull-board/bun
```
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { BunAdapter } from '@bull-board/bun';
import { Queue } from 'bullmq';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new BunAdapter();
serverAdapter.setBasePath('/ui');
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
const bullBoardRoutes = serverAdapter.getRoutes();
Bun.serve({
port: 3000,
routes: {
'/health': { GET: () => Response.json({ status: 'ok' }) },
...bullBoardRoutes,
},
});
```
`getRoutes()` returns an object shaped for `Bun.serve({ routes })`. Spread it alongside your own routes, no separate router to mount.
## Full runnable example
- Simple setup: [`examples/with-bun`](https://github.com/felixmosh/bull-board/tree/master/examples/with-bun)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/elysia.md
---
# Elysia
[Elysia](https://elysiajs.com/) on Bun. `@bull-board/elysia` is an Elysia plugin.
## Install
```sh
npm install @bull-board/api @bull-board/elysia
```
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ElysiaAdapter } from '@bull-board/elysia';
import { Queue } from 'bullmq';
import Elysia from 'elysia';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ElysiaAdapter({
prefix: '/ui',
basePath: '/api/ui',
});
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
options: {
// Works around a Bun build issue caused by eval in the default UI bundle.
uiBasePath: 'node_modules/@bull-board/ui',
},
});
const app = new Elysia({ prefix: '/api' })
.use(await serverAdapter.registerPlugin())
.listen(3000);
```
::: tip
Top-level `await` in the example. Wrap the body in `async function main() { ... }; main()` if your runtime doesn't support it.
:::
`ElysiaAdapter` takes `{ prefix, basePath }` in the constructor instead of `setBasePath()`. `prefix` is the plugin's mount path inside Elysia, `basePath` is the full path the UI calls (including any outer Elysia `prefix`).
## Full runnable example
- Simple setup: [`examples/with-elysia`](https://github.com/felixmosh/bull-board/tree/master/examples/with-elysia)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/express.md
---
# Express
[Express.js](https://expressjs.com/). `@bull-board/express` mounts as a sub-router under any path.
## Install
```sh
npm install @bull-board/api @bull-board/express
```
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { Queue } from 'bullmq';
import express from 'express';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
const app = express();
app.use('/admin/queues', serverAdapter.getRouter());
app.listen(3000);
```
The path in `setBasePath()` must match the mount point in `app.use()`.
## Full runnable examples
- Simple setup: [`examples/with-express`](https://github.com/felixmosh/bull-board/tree/master/examples/with-express)
- With basic auth: [`examples/with-express-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-express-auth)
- With CSRF: [`examples/with-express-csrf`](https://github.com/felixmosh/bull-board/tree/master/examples/with-express-csrf)
- Multiple dashboard instances: [`examples/with-multiple-instances`](https://github.com/felixmosh/bull-board/tree/master/examples/with-multiple-instances)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/fastify.md
---
# Fastify
[Fastify](https://fastify.dev/). `@bull-board/fastify` registers as a plugin.
## Install
```sh
npm install @bull-board/api @bull-board/fastify
```
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { FastifyAdapter } from '@bull-board/fastify';
import { Queue } from 'bullmq';
import Fastify from 'fastify';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = Fastify();
const serverAdapter = new FastifyAdapter();
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
serverAdapter.setBasePath('/ui');
await app.register(serverAdapter.registerPlugin(), { prefix: '/ui' });
await app.listen({ host: '0.0.0.0', port: 3000 });
```
`FastifyAdapter` takes the base path from `setBasePath()` or the `prefix` option on `app.register()`. If you set both, they must match, otherwise asset URLs will 404.
::: tip
Top-level `await` in the example. Wrap the body in `async function main() { ... }; main()` if you're on plain CommonJS.
:::
## Full runnable examples
- Simple setup: [`examples/with-fastify`](https://github.com/felixmosh/bull-board/tree/master/examples/with-fastify)
- With basic auth: [`examples/with-fastify-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-fastify-auth)
- With visibility guard: [`examples/with-fastify-visibility-guard`](https://github.com/felixmosh/bull-board/tree/master/examples/with-fastify-visibility-guard)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/h3.md
---
# H3
[H3](https://h3.unjs.io/). `@bull-board/h3` plugs in as an H3 event handler.
## Install
```sh
npm install @bull-board/api @bull-board/h3
```
```ts
import { createApp, createRouter } from 'h3';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { H3Adapter } from '@bull-board/h3';
import { Queue } from 'bullmq';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const serverAdapter = new H3Adapter();
serverAdapter.setBasePath('/ui');
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
export const app = createApp();
const router = createRouter();
app.use(router);
app.use(serverAdapter.registerHandlers());
```
`registerHandlers()` returns an H3 handler tree. Mount it alongside your router, the adapter handles its own sub-paths under the base path.
## Full runnable example
- Simple setup: [`examples/with-h3`](https://github.com/felixmosh/bull-board/tree/master/examples/with-h3)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/hapi.md
---
# Hapi
[Hapi](https://hapi.dev/). `@bull-board/hapi` registers as a Hapi plugin.
## Install
```sh
npm install @bull-board/api @bull-board/hapi
```
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HapiAdapter } from '@bull-board/hapi';
import { Queue } from 'bullmq';
import Hapi from '@hapi/hapi';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = Hapi.server({ port: 3000, host: 'localhost' });
const serverAdapter = new HapiAdapter();
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
serverAdapter.setBasePath('/ui');
await app.register(serverAdapter.registerPlugin(), {
routes: { prefix: '/ui' },
});
await app.start();
```
`registerPlugin()` wires up Hapi's view and static-file plugins internally, you don't need `@hapi/inert` or `@hapi/vision`. The `routes.prefix` must match the base path.
## Full runnable examples
- Simple setup: [`examples/with-hapi`](https://github.com/felixmosh/bull-board/tree/master/examples/with-hapi)
- With basic auth: [`examples/with-hapi-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-hapi-auth)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/hono.md
---
# Hono
[Hono](https://hono.dev/). `@bull-board/hono` gives you a Hono sub-app.
## Install
```sh
npm install @bull-board/api @bull-board/hono @hono/node-server
```
`@hono/node-server` is only for Node.js. On Bun, Deno, or Workers bring your own serve function, see the [Hono docs](https://hono.dev/docs/getting-started/basic).
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { Queue } from 'bullmq';
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = new Hono();
const serverAdapter = new HonoAdapter(serveStatic);
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
const basePath = '/ui';
serverAdapter.setBasePath(basePath);
app.route(basePath, serverAdapter.registerPlugin());
serve({ fetch: app.fetch, port: 3000 });
```
`serve` and `serveStatic` depend on the runtime, check the example for Node, Bun, Deno variants. `HonoAdapter` takes the runtime's `serveStatic` helper in the constructor so it can serve the bundled UI assets.
## Full runnable example
- Simple setup: [`examples/with-hono`](https://github.com/felixmosh/bull-board/tree/master/examples/with-hono)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/index.md
---
# Server Adapters
One server adapter per framework. The core `@bull-board/api` package is shared. Pick your framework below.
::: tip Try the live demo first — all 9 adapters serve the same UI.
:::
## Adapter matrix
| Framework | Package | Docs |
| --------- | --------------------- | --------------------------------------------------- |
| Express | `@bull-board/express` | [Express →](/bull-board/server-adapters/express.md) |
| Fastify | `@bull-board/fastify` | [Fastify →](/bull-board/server-adapters/fastify.md) |
| NestJS | `@bull-board/nestjs` | [NestJS →](/bull-board/server-adapters/nestjs.md) |
| Koa | `@bull-board/koa` | [Koa →](/bull-board/server-adapters/koa.md) |
| Hapi | `@bull-board/hapi` | [Hapi →](/bull-board/server-adapters/hapi.md) |
| Hono | `@bull-board/hono` | [Hono →](/bull-board/server-adapters/hono.md) |
| H3 | `@bull-board/h3` | [H3 →](/bull-board/server-adapters/h3.md) |
| Elysia | `@bull-board/elysia` | [Elysia →](/bull-board/server-adapters/elysia.md) |
| Bun | `@bull-board/bun` | [Bun →](/bull-board/server-adapters/bun.md) |
## Sails
No dedicated Sails adapter. Sails runs on Express, so use `@bull-board/express` inside a Sails controller. Working example: [`examples/with-sails`](https://github.com/felixmosh/bull-board/tree/master/examples/with-sails).
## Next.js
No dedicated Next.js adapter. Mount bull-board inside a Next.js API route using the Hono adapter (App Router) or the Express adapter (Pages Router). The Vercel deployment needs a small bit of `next.config.js` — see [Next.js & Vercel](/bull-board/recipes/nextjs.md) and the [`with-nextjs-app`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nextjs-app) / [`with-nextjs-pages`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nextjs-pages) examples.
## Shape
Most adapters follow the same three steps:
1. Create a server adapter and set its base path (`setBasePath()`, or constructor options for Elysia).
2. Call `createBullBoard({ queues, serverAdapter })` with your queue adapters.
3. Register the adapter with your app (Express `app.use`, Fastify `app.register`, Bun spreads `getRoutes()` into `Bun.serve`, etc.).
Elysia and Bun are a bit different, the adapter pages show exactly what goes where.
See [Your first dashboard](/bull-board/guide/your-first-dashboard.md) for a concrete Express walkthrough.
---
url: /bull-board/server-adapters/koa.md
---
# Koa
[Koa](https://koajs.com/). `@bull-board/koa` gives you middleware to mount on your app.
## Install
```sh
npm install @bull-board/api @bull-board/koa
```
```ts
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { KoaAdapter } from '@bull-board/koa';
import { Queue } from 'bullmq';
import Koa from 'koa';
const queue = new Queue('my-queue', {
connection: { host: 'localhost', port: 6379 },
});
const app = new Koa();
const serverAdapter = new KoaAdapter();
createBullBoard({
queues: [new BullMQAdapter(queue)],
serverAdapter,
});
serverAdapter.setBasePath('/ui');
app.use(serverAdapter.registerPlugin());
app.listen(3000);
```
`registerPlugin()` returns Koa middleware. Mount it after `setBasePath()`.
## Full runnable example
- Simple setup: [`examples/with-koa`](https://github.com/felixmosh/bull-board/tree/master/examples/with-koa)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.
---
url: /bull-board/server-adapters/nestjs.md
---
# NestJS
[NestJS](https://nestjs.com/). bull-board ships a NestJS module plus a plain adapter you can wire manually.
## Install
```sh
npm install @bull-board/api @bull-board/nestjs
```
Also install the adapter for the HTTP platform your Nest app uses (Express is the default):
```sh
npm install @bull-board/express
# or, for Fastify:
npm install @bull-board/fastify
```
## Module-based setup (recommended)
Register `BullBoardModule.forRoot()` in your root module, then `BullBoardModule.forFeature()` per queue from the feature module.
```ts
// app.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { FeatureModule } from './feature/feature.module';
@Module({
imports: [
BullModule.forRoot({
connection: { host: 'localhost', port: 6379 },
}),
BullBoardModule.forRoot({
route: '/queues',
adapter: ExpressAdapter, // or FastifyAdapter from '@bull-board/fastify'
}),
FeatureModule,
],
})
export class AppModule {}
```
```ts
// feature/feature.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
@Module({
imports: [
BullModule.registerQueue({ name: 'feature_queue' }),
BullBoardModule.forFeature({
name: 'feature_queue',
adapter: BullMQAdapter, // or BullAdapter for Bull v3
}),
],
})
export class FeatureModule {}
```
`forRoot()` options:
- `route`: base path where the dashboard is mounted.
- `adapter`: server adapter class (`ExpressAdapter` or `FastifyAdapter`).
- `boardOptions`: forwarded to `createBullBoard` (e.g. `uiConfig`, `uiBasePath`).
- `middleware`: optional Express/Fastify middleware (basic auth, etc.).
`forFeature()` options (pass either `name` or `queue`):
- `name`: queue name registered with `BullModule.registerQueue`. The module resolves the instance from Nest's DI container.
- `queue`: a queue instance to register directly, instead of resolving it by `name`. See [Queues with the same name](#queues-with-the-same-name) below.
- `adapter`: `BullMQAdapter` or `BullAdapter`.
- `options`: queue adapter options like `readOnlyMode` or `description`.
To register several queues at once, pass multiple option objects:
```ts
BullBoardModule.forFeature(
{ name: 'emails', adapter: BullMQAdapter },
{ name: 'billing', adapter: BullMQAdapter },
);
```
### Queues with the same name
`@nestjs/bullmq` builds a queue's DI token from its `name` alone — the `prefix` is not part of it. So if you run the same queue name under two prefixes (a common multi-tenant setup), both share one DI token and a `name` lookup can only ever return one of them. Registering both by `name` makes one queue shadow the other on the board.
Pass the instances directly via `queue` instead. Hold the queues somewhere you control — a provider, a service, wherever you created them — and hand them to `forFeature`:
```ts
@Module({
imports: [
BullBoardModule.forFeature(
{ queue: emailsTenantA, adapter: BullMQAdapter, options: { prefix: 'tenant-a:' } },
{ queue: emailsTenantB, adapter: BullMQAdapter, options: { prefix: 'tenant-b:' } },
),
],
})
export class FeatureModule {}
```
The board keys entries by `prefix` + name, so the two show up as `tenant-a:emails` and `tenant-b:emails`. Set each adapter's `prefix` to match the queue's own prefix so the labels line up.
There's also `BullBoardModule.forRootAsync()` which accepts `useFactory`, `imports`, `inject` for dynamic config.
You can inject the board instance anywhere:
```ts
import { Controller } from '@nestjs/common';
import { BullBoardInstance, InjectBullBoard } from '@bull-board/nestjs';
@Controller('ops')
export class OpsController {
constructor(@InjectBullBoard() private readonly board: BullBoardInstance) {}
}
```
## Plain adapter setup
If you'd rather wire the server adapter yourself (custom middleware, existing Nest conventions), do it in a module's `configure()`:
```ts
import {
DynamicModule,
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { Queue } from 'bullmq';
@Module({})
export class QueuesModule implements NestModule {
static register(): DynamicModule {
return {
module: QueuesModule,
imports: [
BullModule.forRoot({
connection: { host: 'localhost', port: 6379 },
}),
BullModule.registerQueue({ name: 'test' }),
],
};
}
constructor(private readonly testQueue: Queue) {}
configure(consumer: MiddlewareConsumer) {
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/queues');
createBullBoard({
queues: [new BullMQAdapter(this.testQueue)],
serverAdapter,
});
consumer.apply(serverAdapter.getRouter()).forRoutes('/queues');
}
}
```
## Full runnable examples
- NestJS module (recommended): [`examples/with-nestjs-module`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nestjs-module)
- Plain adapter: [`examples/with-nestjs`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nestjs)
- Fastify platform with auth: [`examples/with-nestjs-fastify-auth`](https://github.com/felixmosh/bull-board/tree/master/examples/with-nestjs-fastify-auth)
## Next steps
- [UIConfig](/bull-board/configuration/ui-config.md): title, logo, locale, polling.
- [Read-only mode](/bull-board/recipes/read-only-mode.md): disable destructive actions.
- [Visibility guard](/bull-board/recipes/visibility-guard.md): scope visible queues per request.
- [Formatters](/bull-board/recipes/formatters.md): rewrite job fields for the UI.