Standalone CLI
Sometimes you don't want to wire bull-board into an app at all, you just want to look at a Redis instance. @bull-board/cli does that: point it at a Redis URL and it finds the Bull and BullMQ queues stored there, then serves the same dashboard UI you'd get from any server adapter.
It needs Node.js 20 or newer. That starts the dashboard on http://127.0.0.1:3000 and opens it in a browser. This is a tool for local development, evaluating bull-board before wiring it into your app, or looking at a queue on infrastructure you've tunnelled to. It is not a replacement for mounting the adapter in your own server: there's no auto-login, no framework-level auth to inherit, and every option has to be passed on the command line, an env var, or a config file instead of code.
Discovery
On startup the CLI scans Redis for keys matching <prefix>:<queue-name>:meta (BullMQ) and <prefix>:<queue-name>:id (Bull) under each configured prefix, and builds a real Queue instance for every queue it finds. The default prefix is bull, which is what Bull and BullMQ both use unless you've changed it yourself. If your queues use a different prefix, pass --prefix.
By default it rescans every 10 seconds, so a queue created after the dashboard started still shows up without a restart. Pass --scan-interval 0 to scan once at startup and stop.
If you'd rather skip discovery entirely, --queues takes an explicit, comma separated list of queue names to serve. The CLI still has to work out whether each one is Bull or BullMQ, but it no longer scans Redis for anything else under the prefix.
When Redis isn't reachable
The dashboard still opens even if Redis is down or the URL is wrong. Instead of a dead terminal, npx @bull-board/cli serves a diagnostic page at the same URL, explaining what it tried to connect to, the underlying error, and the likely cause: Redis isn't running, the port is wrong (6379 is the default), it's in a container whose port isn't published, it needs credentials, or it needs TLS and therefore a rediss:// URL. A --user/--password you've set still guards this page: the URL it names is never served to a request without the right credentials.
The process stays alive and keeps retrying every 3 seconds. The page polls its own status and reloads on its own the moment Redis answers, switching to the real dashboard with no restart and no second command.
That healing only applies before the first successful connection. Once the dashboard is live, it stays live for the rest of the process, even if Redis goes away later: the diagnostic page does not come back, and the dashboard's own API requests simply stop returning until Redis is reachable again. Ctrl-C still works during that window; the CLI's shutdown is bounded so it never hangs waiting on a dead connection.
A second, rarer page shows up if the CLI reaches Redis but something after that fails for a reason that has nothing to do with connectivity, such as an ACL-restricted user that can authenticate but not run SCAN. That page names the real error too, but does not promise a retry, since reconnecting again would not fix it; restart the CLI once the underlying problem is addressed.
For scripts and CI, retrying forever is the wrong default: they want a non-zero exit code, not a process that waits indefinitely. Pass --no-retry and the CLI prints the error and exits 1 as soon as the first connection attempt fails, without ever opening a port.
Options
Environment variables
Every flag has an environment variable equivalent, so you can configure the CLI in a container or a systemd unit without a command line to edit:
Settings resolve in this order: a command line flag wins, then the matching environment variable, then the config file, then the built-in default. That applies field by field, so you can set a Redis URL in the environment and still override just the port with a flag on one particular run.
--browser picks the command used to open the dashboard. Three things can name it, and they win in this order: --browser on the command line, then BULL_BOARD_BROWSER, then a plain exported $BROWSER. BULL_BOARD_BROWSER exists so you can set one for the CLI without touching $BROWSER globally. With none of them set, the CLI falls back to the platform opener: open on macOS, start on Windows, xdg-open elsewhere.
A browser key in the config file sits below all three, because the config file is the last step in the resolution order above. That is worth knowing: an exported $BROWSER left over from another tool silently overrides a browser you set in the config file.
A command with arguments works too, for example --browser 'open -a Safari'. The value is split on whitespace and the URL is appended as the last argument, and it never goes through a shell, on any platform, Windows included.
Because the split is on whitespace, a single path that contains spaces does not survive it. The common macOS form /Applications/Google Chrome.app/Contents/MacOS/Google Chrome, and CRA's BROWSER="google chrome", both break: every word after the first is treated as an argument to a command that does not exist at that path. It does not quietly fall back to the platform opener either. It fails to spawn, exactly as naming any other uninstalled command would, and the CLI prints "Could not open a browser automatically" and leaves you to open the URL yourself.
--no-open skips opening a browser at all, whatever --browser or $BROWSER say.
Config file
For anything more than a couple of flags, use a config file. Without --config, the CLI looks for bull-board.config.mjs, .js, .cjs, or .json in the current directory, in that order. .cjs and .json are always read as CommonJS/JSON; a plain .js file is read as CommonJS first and retried as ESM if that fails, so either module.exports or export default works there.
redis takes a connection URL as above, or a full ioredis options object when a URL can't express the connection, which is what Redis Sentinel needs.
uiConfig is the same object you'd pass to createBullBoard({ options: { uiConfig } }) in code, see the UIConfig reference for the full set of fields. Board-wide title, logo, locale, and so on all live there, not at the top level of the config file.
queues is dual purpose: an array (queues: ['emails', 'webhooks']) is equivalent to --queues, a comma-free explicit list that skips discovery. An object, as above, instead sets per-queue QueueAdapterOptions overrides keyed by queue name, the same options you'd pass to new BullMQAdapter(queue, options) directly. A queue's own readOnlyMode: true always wins even when the board as a whole isn't read-only, but it can't turn read-only mode back off for a single queue once --read-only is set globally. A field can't do both jobs in the same file: pick the array form to restrict which queues are served, or the object form to configure the ones discovery finds.
Redis Sentinel
A Sentinel deployment has no fixed master address, so there is no single URL to point --redis at. --sentinel takes the sentinel nodes instead and lets ioredis work out which Redis is currently the master:
Each entry is a host or host:port, with the port defaulting to 26379. An IPv6 literal is all colons, so it needs brackets to carry a port: [2001:db8::1]:26379. Without them the whole entry is read as a host and gets the default port. --sentinel-name is the master group name from your sentinel configuration, the same string you would pass as name to ioredis, and it is required: sentinels can monitor more than one group, so there is nothing sensible to guess.
--sentinel and --redis are mutually exclusive. Setting both is an error rather than a quiet precedence rule, so a leftover BULL_BOARD_REDIS_URL in a container's environment cannot silently send the dashboard to the wrong Redis.
The CLI holds one ioredis connection and hands it to everything else it builds, so failover handling is not a separate feature: the queue instances, Bull's second subscriber connection, and the --history recorder all follow the master through a failover because they share that connection. During the failover window the dashboard's own API requests fail the way they do for any dropped connection, and recover once ioredis has re-resolved the master.
Credentials
A Redis URL carries its own credentials. Sentinel mode has no URL, so they get their own flags:
--sentinel-password is separate because sentinel nodes usually have a password of their own, distinct from the one guarding the data nodes.
These four are rejected alongside a Redis URL rather than merged into it. ioredis treats an explicitly passed option as a default and lets the URL win, so a --redis-password next to a URL that already carries one would be silently discarded. Refusing the combination outright is clearer than a flag that sometimes takes effect. With a URL, put the credentials in the URL.
Everything else
The flags cover the common deployment. For anything past it, the config file's redis key also accepts a full ioredis options object, passed through to the client untouched:
That is the way to reach TLS to the sentinel nodes, natMap for a NAT-ed cluster, preferredSlaves, or a custom sentinelRetryStrategy. The object form is not limited to Sentinel: a plain { host, port, tls } works too, wherever a URL is awkward. Credential flags still apply on top of an object, so a password can stay in the environment instead of the file.
Redis Cluster
--cluster takes a comma-separated list of startup nodes and connects through them, the way --redis and --sentinel do for their topologies. ioredis discovers the rest of the cluster from any node that answers, so listing two or three is enough:
--redis-username and --redis-password apply here as they do in sentinel mode. --redis-db does not: a cluster only has database 0, and passing it is an error rather than a silent no-op.
Your queues need the hash-tagged prefix BullMQ already asks for in cluster mode, so one queue's keys stay in one slot:
Point --prefix at the same string, braces included. Discovery scans every master rather than one, since SCAN carries no key for the client to route by.
Only BullMQ queues are served. Bull 3 builds its keys without a hash tag and its Lua touches several at once, so on a cluster every command it issues is a CROSSSLOT away from failing; such a queue is skipped with a warning naming it, rather than shown on the board with every action broken. BullMQ queues on the same cluster are unaffected.
The Redis stats panel reports the cluster as a whole, summing memory and client counts across the masters. --history works, storing everything under a single hash slot so its rollup script stays legal. The historical metrics recipe covers what that means for the key layout.
Basic auth
--user and --password add HTTP basic auth in front of the dashboard. Both are required together:
This is enough for a queue you've tunnelled to or a small internal box. It is not the layered, session-aware auth described in Add basic auth, which covers login flows and framework-integrated auth for an app you're embedding the dashboard into.
Historical metrics
BullMQ's own metrics are a per-minute ring buffer capped at maxDataPoints, so the throughput chart can't look back further than that buffer reaches. --history turns on the long-retention path from the historical metrics recipe without wiring @bull-board/metrics into an app of your own:
That registers RedisMetricsHistoryProvider on the Redis connection the dashboard already holds, so every queue chart gains a 60m / 7d / 30d / 90d range selector and a cross-queue "Metrics history" page shows up in the sidebar. It flips showMetrics on too, because the range selector lives inside the per-queue chart and that chart doesn't render without it.
It also writes. A MetricsRecorder runs in the CLI process and once a minute copies each queue's completed and failed counters into long-retention buckets, then samples wait time, run time and the age of the oldest waiting job. The recorder follows discovery rather than a fixed list: a queue that shows up between rescans starts recording on the next tick, and one that disappears stops. No restart either way.
--history-retention-days sets how long history is kept, 90 days by default. It moves the hourly and daily windows only and leaves minute-level detail at 7 days, since that tier holds essentially all the bytes. Per-tier retention, the key namespace, the snapshot interval and turning latency sampling off go in the config file under a history key:
What it writes
Recording writes to the same Redis your queues live in, under the bull-board:metrics: namespace, and never touches a key Bull or BullMQ owns. Set history.prefix in the config file to move that namespace, which is what keeps two boards on one Redis from sharing a history. Redis TTLs enforce retention, so there's nothing to prune by hand. Storage footprint has the measured numbers; the short version is roughly 1.1 MB per queue for the counters at the default retention plus about 250 KB for latency, and an idle queue costs nothing.
--read-only stops the writing and keeps the reading, so the board serves whatever another process has recorded. That's what you want when your workers already run a MetricsRecorder of their own and the CLI is only there to look at the result. The config file can ask for the opposite with history: { record: true } alongside --read-only, for a board that mustn't touch your queues but does own its history.
Running several instances with --history at once is safe. The minute upsert applies a delta against the value already stored rather than adding to it, so a minute recorded twice still counts once, and latency sampling takes a short per-queue lease, so only one process scans a given queue on a given tick.
When the charts stay empty
Completed and failed history is copied out of BullMQ's own metrics buffer, which stays empty unless your workers were built with metrics enabled:
The CLI warns about this at startup when no discovered queue has any metrics data, because otherwise a queue whose workers never enabled metrics looks exactly like an idle one. Latency and queue age need nothing from your workers, since they're read from sorted sets BullMQ maintains anyway. One exception there: a queue using removeOnComplete: true has its jobs deleted before the next tick can read them, so it never accumulates latency data.
This is BullMQ only. Bull v3 has no native metrics to snapshot, and BullMQ v6 queues backed by PostgreSQL aren't discoverable from the CLI in the first place.
Docker
The CLI also ships as an image, ghcr.io/felixmosh/bull-board, so a container next to your Redis needs no Node on the host and doesn't re-resolve the package from npm every time it starts:
The entrypoint is the CLI, so every flag and variable on this page works there too. Run with Docker covers the tags, a Compose file, mounting a config file, and putting it behind a reverse proxy.
Queues written by something other than Node
BullMQ has an official Python package, and gets written to from Go, Ruby, and other languages over the raw Redis protocol, since the job format is just a set of Redis keys, not a Node API. Those teams have never had a way to use bull-board, because every server adapter assumes a Node HTTP app to mount into. The CLI doesn't have that assumption: it scans Redis for the same keys regardless of what wrote them, and builds a Queue instance the same way whether the producer was bullmq or python-bullmq.
The caveat is the same one that applies everywhere else in bull-board: the dashboard can only show what Bull and BullMQ store in Redis. A producer that doesn't write jobs in the format either library expects may show up incompletely, or not render some fields at all.
Driving it from a script or an agent
The CLI serves the same JSON API the UI itself calls, so a shell script or an agent debugging a stuck job can query it instead of reading Redis keys by hand or writing a throwaway script:
--no-open skips the browser launch, which matters in a script or a headless agent session where there's nothing to open a browser on. --port pins the port so the caller knows where to send the request instead of parsing it out of stdout.
What it doesn't do yet
Discovery only reads Redis. BullMQ v6 queues backed by PostgreSQL aren't found or servable through the CLI; use a server adapter in your own app for those, see the PostgreSQL backend recipe.
Bull 3 queues aren't servable on a Redis Cluster, since Bull builds its keys without a hash tag. They're skipped with a warning; BullMQ queues on the same cluster work normally.
--prefix also doesn't take wildcards. A queue's Redis key and its name can both contain colons, so there's no reliable way to guess where a wildcard prefix ends and the queue name begins. List the prefixes you need explicitly instead, for example --prefix bull,tenant-a,tenant-b.