We had a classic setup: a NestJS API receives requests, drops a job into a BullMQ queue backed by Redis, and a worker process picks it up. Locally, this is perfect. Docker Compose gives you Postgres and Redis, the worker polls Redis, life is simple.
Then we tried to deploy it to Cloud Run.
The Problem
BullMQ is pull-based. The worker connects to Redis and polls for jobs. This means:
- The worker must always be running to pick up jobs
- Cloud Run's
min_instances = 0doesn't work. If the instance shuts down, jobs pile up with nobody listening - You need
cpu_idle = false(always-allocated CPU) so the event loop stays active - You're paying ~$15-20/mo for an always-on instance that might process a handful of jobs a day
For an early-stage product, that's not a rounding error. That's the entire infra budget.
The Cloud-Native Alternative
Cloud Run is built for request-driven workloads. Google Cloud Pub/Sub can push messages to Cloud Run via HTTP. The flow becomes:
flowchart LR
api["API"] --> pubsub["Pub/Sub\ntopic"] --> push["HTTP push"] --> worker["POST /push-handlers/\nprocess-job"]
Now the worker is just an HTTP endpoint. Cloud Run spins it up when a message arrives, processes it, and scales back to zero. Cost: effectively nothing at low volume.
But we still need BullMQ for local development. Running a Pub/Sub emulator locally is friction we don't need when docker compose up already gives us Redis.
The Strategy Pattern
Instead of littering the codebase with if (driver === 'pubsub') checks, we split the worker into three pieces:
Shared business logic lives in a standalone service. It doesn't know or care how it was triggered.
Two worker modules each wire the service to a different trigger mechanism:
WorkerBullMQModule
├── BullModule (Redis connection)
├── JobProcessor (@Processor decorator, polls Redis)
└── JobService
WorkerPubSubModule
├── PushHandlerController (POST endpoint, receives Pub/Sub push)
└── JobService
One entrypoint reads an env var and bootstraps the right module:
const driver = process.env.QUEUE_DRIVER || 'bullmq';
const WorkerModule = driver === 'pubsub'
? WorkerPubSubModule
: WorkerBullMQModule;
No conditionals in module definitions. No process.env checks scattered across the codebase. The strategy is selected once, at the top.
The Publishing Side: Orchestrator/Provider
On the publishing side (API to queue), we followed an orchestrator/provider pattern:
flowchart TB
qm["QueueModule.forRootAsync(config)"] --> qs["QueueService\n(orchestrator)"]
qs --> bull["BullMQProvider\n→ Redis queue"]
qs --> ps["PubSubProvider\n→ Pub/Sub topic"]
QueueService selects the active provider based on config and delegates publish(). The calling code just calls queueService.publish({ topic, data }). It doesn't know if the message ends up in Redis or Pub/Sub.
This is the same pattern you'd use for email providers (SES/SendGrid), payment gateways (Stripe/Razorpay), or any integration where you want the business logic decoupled from the infrastructure.
Securing the Push Endpoint
In production, the worker is locked down with two layers:
- Network: Cloud Run ingress set to
INGRESS_TRAFFIC_INTERNAL_ONLY. Pub/Sub push is Google-internal traffic, everything else is rejected - Identity: The Pub/Sub subscription uses a dedicated service account with an OIDC token. Cloud Run only grants
roles/run.invokerto that SA. Unauthorized requests get a 403 before they touch your code
No auth middleware needed. Cloud Run handles it.
The Cost Difference
| BullMQ (always-on) | Pub/Sub (scale to zero) | |
|---|---|---|
| Worker compute | ~$15-20/mo | ~$0-3/mo |
| Redis (managed) | ~$15/mo | $0 |
| Pub/Sub | $0 | ~$0.01/mo |
| Total | ~$30-35/mo | ~$0-3/mo |
Per environment. If you run dev + staging, that's $60-70/mo saved before you even have customers.
What I'd Do Differently
Start with Pub/Sub from day one if you know you're deploying to Cloud Run. We built the BullMQ pipeline first because it's the NestJS default, then had to refactor. The abstraction is clean, but the work was avoidable.
Don't over-abstract the queue layer if you only have one queue. A simple interface and two implementations is enough. Only reach for the full orchestrator/provider pattern if you already have it in your codebase or expect multiple queue types.
The Pattern Itself
This is textbook strategy pattern: define a family of algorithms (BullMQ polling, Pub/Sub push), encapsulate each one, and make them interchangeable. The business logic doesn't change. The trigger mechanism is pluggable.
It shows up naturally when your local development environment and your production environment have fundamentally different infrastructure constraints. Docker Compose gives you Redis for free. Cloud Run gives you scale-to-zero for free. The strategy pattern lets you have both without the code caring which world it's in.