Back to Blog

BullMQ Sandboxed Processors Failing to Load in Docker Production Build

Published: July 29, 2026
BullMQ Sandboxed Processors Failing to Load in Docker Production Build

BullMQ sandboxed processor failing to load in your Docker production build is a bug that reads as if the processor code itself has an error, when the actual problem is almost always a file path that made sense in one environment and stopped making sense the moment your code got compiled and containerized. Locally, running through ts-node or a similar dev runner, the path just works. Inside the built Docker image, running compiled JavaScript from a dist folder with a different directory shape, the exact same path logic points at a file that doesn't exist there.

Short answer: sandboxed processors are loaded via a direct require() call in a separate child process, which means the path you give BullMQ has to point at the actual compiled .js file — not the TypeScript source, and not a path built with __dirname before you account for what your build step actually produces. Get the compiled path right, and confirm your Docker multi-stage build actually copies that specific file into the final image.

A colorful outdoor sandbox with toy trucks and molds, representing the sandboxed processor BullMQ runs in its own isolated child process

Why Sandboxed Processors Break Specifically in Compiled Builds

BullMQ's own documentation on sandboxed processors is explicit about the mechanism: rather than passing a function reference to Worker, you pass a file path, and BullMQ spawns a separate child process that require()s that file directly. This is fundamentally different from how the rest of your application code runs — your main entry point and its regular imports get resolved and bundled as part of the normal Node.js module system, but a sandboxed processor's path is resolved and loaded independently, at the moment the Worker starts up.

In local development, if you're running through ts-node, that tool is quietly handling TypeScript compilation on the fly for everything, including a path that happens to point at a .ts file — which works, but only because of that specific dev-time tooling. Once your build step compiles everything into plain JavaScript in a dist directory, ts-node isn't in the picture anymore, __dirname resolves to a different actual location, and a processor path built the same way as before either points at a .ts file that no longer gets special treatment, or a directory structure that no longer matches what existed pre-build.

(If you've been staring at the processor function's own logic looking for a bug — it's very likely fine. The function never got the chance to run. The problem is entirely in whether BullMQ could find and load the file at all.)

Fix 1: Reference the Compiled .js Path, Explicitly

The direct fix is making sure the path passed to Worker always points at the compiled output, regardless of environment:

TypeScript
1// worker.ts (source) — path must point at the compiled .js output, not this .ts file
2import { Worker } from 'bullmq';
3import path from 'path';
4
5const processorPath = path.join(__dirname, 'report-processor.js');
6
7const worker = new Worker('reports', processorPath, {
8  connection: { host: process.env.REDIS_HOST, port: 6379 },
9});
TypeScript
1// report-processor.ts (source) — compiles to report-processor.js, which is what's actually loaded
2import { SandboxedJob } from 'bullmq';
3
4module.exports = async (job: SandboxedJob) => {
5  await generateReport(job.data);
6};

After your build step compiles worker.ts and report-processor.ts into dist/worker.js and dist/report-processor.js, running dist/worker.js means __dirname resolves to dist, and path.join(__dirname, 'report-processor.js') correctly resolves to dist/report-processor.js — the actual compiled file that exists at runtime. The critical discipline is never hardcoding a .ts extension or assuming a src-relative path will still be valid once everything's been compiled into a different directory tree.

A yellow dead-end road sign against a clear blue sky, representing a processor file path that resolved correctly in development but leads nowhere once the code is compiled and containerized

Fix 2: Confirm the Docker Multi-Stage Build Actually Copies the Compiled File

Getting the path logic right doesn't help if the file it points to was never copied into your final Docker image in the first place — a mistake that's easy to make in a multi-stage build where the production stage only contains exactly what its COPY instructions specify:

Dockerfile
1# Dockerfile
2FROM node:20-alpine AS build
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7RUN npm run build
8
9FROM node:20-alpine AS production
10WORKDIR /app
11COPY package*.json ./
12RUN npm ci --omit=dev
13COPY --from=build /app/dist ./dist
14CMD ["node", "dist/worker.js"]

Docker's own multi-stage build documentation confirms exactly this behavior: the final stage only contains what its own instructions explicitly bring in from earlier stages. COPY --from=build /app/dist ./dist needs to actually include report-processor.js alongside worker.js — if your build step compiles them into different output locations, or your .dockerignore accidentally excludes something it shouldn't, the production stage can end up missing exactly the one file the sandboxed processor is trying to require(), even though the overall Docker build reports success.

A warehouse worker checking inventory on a tablet surrounded by stacked boxes, representing the discipline of confirming every compiled file actually made it into the final Docker production stage

Fix 3: Consider Whether You Need a Sandboxed Processor at All

If this class of path problem keeps recurring, it's worth asking whether sandboxing is actually earning its complexity for the job in question. A standard, non-sandboxed processor — just a function passed directly to Worker — avoids the separate-file, separate-process loading model entirely:

TypeScript
1// worker.ts — a standard processor, no separate file or path resolution needed
2import { Worker } from 'bullmq';
3
4const worker = new Worker(
5  'reports',
6  async (job) => {
7    await generateReport(job.data);
8  },
9  { connection: { host: process.env.REDIS_HOST, port: 6379 } },
10);

The tradeoff is losing sandboxing's process isolation — a crash inside a non-sandboxed processor can affect the worker process itself, where a sandboxed one crashing stays contained. That isolation is genuinely worth it for risky or resource-heavy job logic. For straightforward jobs, the simpler model removes an entire category of "did the path resolve correctly in this specific environment" bugs for free.

The Opinion Part

Here's the pattern worth naming, because it's the same one behind several other bugs in this genre: any file path built from __dirname, any reference to "the compiled output," is making an assumption about your build process that's easy to get right once and never revisit — until the build process itself changes shape, and the assumption quietly stops holding. A sandboxed processor's path is a small, specific instance of a much larger discipline: anything your code assumes about where files physically live after a build step needs to be verified against what the build actually produces, not what seemed reasonable when you first wrote the path. That verification costs one Docker build and a docker run smoke test. Skipping it costs a production incident where a job queue silently stops processing because the one file it needed was never actually where the code expected.

Conclusion

If a BullMQ sandboxed processor is failing to load in a Docker production build, check the processor's file path first — it needs to reference the compiled .js output, resolved relative to wherever the running compiled code actually lives, not the TypeScript source or a dev-environment assumption. Then confirm your multi-stage Dockerfile's final stage actually copies that specific compiled file, since a successful build doesn't guarantee everything the app needs made it into the image that ships.

If you're setting up the surrounding Docker build for BullMQ workers more broadly, our multi-stage build optimization guide covers the general version of this "did the build actually include what I expected" discipline, and if the worker itself needs to run continuously rather than inside a short-lived container invocation, our BullMQ-on-Lambda guide covers that related architectural constraint.

Point the path at what actually exists after the build, confirm the Dockerfile copies it, and let the sandboxed processor load the way it was supposed to the whole time.

Frequently Asked Questions

Because sandboxed processors are loaded as separate child processes that require() a specific file path directly, rather than being bundled into your application code the way a normal import would be. In development, that path often resolves correctly by coincidence — ts-node or a similar runner handles the TypeScript file directly. In a compiled Docker build, the path needs to point at the actual compiled .js file, and if it's still referencing the TypeScript source or an unbuilt path, the require() call fails.

__dirname resolves to wherever the currently executing compiled file lives, which changes once TypeScript compiles your source into a separate dist folder with a different directory structure. A path built with __dirname before the build and one built with __dirname after compiling into dist can point at genuinely different locations, which is exactly why a processor path that resolves correctly with ts-node can point at nothing once the code is actually compiled and run from dist.

Always the compiled .js file, even in a TypeScript project. Sandboxed processors are loaded via a direct require() call in a separate child process, which needs an actual JavaScript file it can load — not a TypeScript source file that needs a compiler to interpret. This differs from your main application code, which typically runs through ts-node in development or gets compiled as a whole before the entry point ever executes.

Forgetting that a multi-stage Dockerfile's final production stage only contains what's explicitly copied into it — if your build stage compiles the processor file into dist/processor.js correctly, but the final stage's COPY instruction doesn't include that specific file or directory, the compiled output the sandboxed processor needs simply doesn't exist in the image that actually ships, even though the build itself succeeded without errors.

Yes — a standard, non-sandboxed processor function defined inline in the same file as your Worker constructor avoids the separate-file, separate-process loading model entirely, since it's just a regular function reference rather than a path BullMQ has to resolve and require() independently. The tradeoff is losing sandboxing's process isolation (a crash in the processor won't take down the whole worker), which matters more for genuinely risky or resource-heavy job logic than for straightforward jobs.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio