Skip to main content
BullMQ is a job queue for Node.js that keeps its jobs in Redis. On Sevalla, the application’s web process adds jobs to a queue in a Sevalla Redis or Valkey database, and a background worker, a second process that runs from the same image, takes them off the queue and runs them. This guide sends welcome emails from an Express application in the background with BullMQ 6, so the request that asks for one returns at once.

At a glance

What this guide adds to the Express application:

Environment variables

The web process and the worker both get them. The Express application’s own variables are in its table. The internal connection also adds REDIS_URL, which the code doesn’t read.

Before you begin

  • An Express application deployed on Sevalla, as in Deploy Express. It pins Node.js 24.
  • Redis or Valkey on your computer, to try the queue locally. With Docker: docker run -d --name storefront-redis -p 6379:6379 valkey/valkey:9.

Create the database

1

Create the database

In the sidebar, click Databases, then click Create database. Under Database, choose Redis or Valkey and the version. Enter storefront-redis as the Display name, choose the same Data center as the application and a Size, then click Create database. See Create a database for every field.
2

Connect it to the application

In the sidebar, click Applications, then click the application. Click the Networking tab. Under Connected services, click Add internal connection. Choose storefront-redis as the Service, turn on Add environment variables to the application, and click Add internal connection.
The connection adds REDIS_HOST, REDIS_PORT, REDIS_PASSWORD and REDIS_URL to the application, which apply at the next deployment. Environment variables belong to the application, so the web process and the worker both get them. See Connect an application.

Install BullMQ

BullMQ 6 connects to Redis with ioredis but doesn’t install it: add it yourself.

Create the queue

Create the queue in a module that the web process and the worker both import:
queue.js
  • BullMQ opens its own connections from these options and sets what each one needs, such as maxRetriesPerRequest: null for the worker’s blocking connection.
  • The separate REDIS_* variables work for both engines. For a Valkey database, REDIS_URL starts with valkey://, which ioredis doesn’t parse.
  • attempts and backoff retry a failed job twice, 1 second and then 2 seconds after it fails. See Retry failed jobs.
  • removeOnComplete and removeOnFail delete finished jobs after 24 hours and failed jobs after 7 days, so the queue doesn’t grow without limit in the database’s memory.

Add jobs from the web process

Replace server.js with a server that adds a job for each request and reports a job’s state:
server.js

Process jobs in a worker

Create the worker, which runs in its own process:
worker.js
concurrency: 5 lets one worker instance run 5 jobs at a time. The SIGTERM handler makes the worker stop taking new jobs and wait for the ones it’s running; Shut down cleanly during deployments explains why it matters. To try it locally, start the server and the worker in two terminals, with the database on your computer:
Then add a job with curl -X POST http://localhost:3000/emails -H "Content-Type: application/json" -d '{"to":"ana@example.com"}'. The response is {"id":"1"}, and the worker prints Sending job 1 to ana@example.com, attempt 1, then Job 1 completed 5 seconds later.

Create the background worker

1

Open the Processes tab

In the sidebar, click Applications, then click the application. Click the Processes tab, then click Create process.
2

Create the worker

Choose Background worker. Enter Worker as the Name and exec node worker.js as the Custom start command. Keep the Resource at H1 and the instance count at 1, then click Create process.
The Create process sheet with Background worker selected, and the Name, Custom start command, Resource and Scaling fieldsThe Create process sheet with Background worker selected, and the Name, Custom start command, Resource and Scaling fields
Start the command with exec. Sevalla runs a custom start command with /bin/sh -l -c, and the shell stays the container’s main process: it doesn’t pass the stop signal on, so node worker.js on its own never receives SIGTERM. exec replaces the shell with Node.js, which then gets the signal. The worker runs from the same image as the web process, and starts with the next deployment. See Background workers.

Deploy and verify

Commit and push the changes, package-lock.json included: Ingot installs BullMQ from it with npm ci, and the web process still starts with the start script. With auto-deploy on, the push starts a deployment; otherwise, start one with Deploy now on the application’s Overview tab. The deployment also gives both processes the variables the internal connection added. When the deployment’s status changes to Deployed:
  1. Add a job. Use your application’s domain instead of storefront-x7k2m.sevalla.app:
    The response is {"id":"1"}, with the status 202.
  2. On the application’s Logs tab, choose the Worker process. It logs Sending job 1 to ana@example.com, attempt 1 and, 5 seconds later, Job 1 completed. See Runtime logs.
  3. Check the job’s state:
    The response is {"id":"1","state":"completed","result":{"sentTo":"ana@example.com","sentAt":"..."}}.
If the worker’s logs show Port should be >= 0 and < 65536. Received type number (NaN)., the REDIS_* variables aren’t set in the running application: add the internal connection with Add environment variables to the application turned on, then deploy.

Retry failed jobs

When the job function throws, BullMQ runs the job again, up to the attempts set in queue.js, waiting longer each time: with backoff exponential and delay 1000, the second attempt starts 1 second after the first fails, and the third 2 seconds after the second. The failed event fires after every failed attempt; after the last one, the job’s state is failed and it stays in Redis for 7 days. For an error that retrying can’t fix, such as an invalid address, throw UnrecoverableError from bullmq: the job fails at once, without the remaining attempts.

Shut down cleanly during deployments

Each deployment starts new worker instances, then stops the old ones. Sevalla waits 5 seconds, sends SIGTERM, and stops the instance if it’s still running 30 seconds after the shutdown began. See How processes run. With exec node worker.js and the SIGTERM handler in worker.js, an old instance stops taking jobs and finishes the ones it’s running, while the new instance takes the next ones. Its logs end with:
Without exec, the signal never reaches the worker, and Sevalla stops it 30 seconds into the shutdown, in the middle of its jobs. BullMQ doesn’t lose those jobs: once their lock expires, it marks them as stalled and a running worker starts them again from the beginning, about a minute later. Write jobs so that running one twice does no harm, and keep each job well under 25 seconds, or split long work into several jobs, so an old instance can finish what it started.

Schedule repeating jobs

BullMQ can add a job on a schedule. Add a job scheduler to worker.js, after the import lines, and import the queue too:
worker.js
The schedule lives in Redis, so it survives deployments, and every worker instance can run its jobs; only one runs each one. Scheduled jobs get the same retries as the others. For work that doesn’t need the queue, a Sevalla cron job runs a command on a schedule instead:

Scale the worker

To run more jobs at once, raise concurrency for jobs that mostly wait on other services, or add instances to the Worker process on the Processes tab for jobs that use CPU. Every instance takes jobs from the same queue, and BullMQ gives each job to one worker at a time. See Scaling.

Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Add a Dockerfile and a .dockerignore to the root of the repository:
Dockerfile
The worker runs from the same image with its own start command, which replaces the image’s CMD. Keep exec node worker.js: Sevalla still starts it with /bin/sh -l -c, and exec makes Node.js the container’s first process, which receives SIGTERM.
.dockerignore
Commit and push both files. Ingot doesn’t use the Dockerfile, so you can keep it in a repository that builds with Ingot, but it adds the .dockerignore patterns to its own, and its build plan lists them in an Ignored row. Then switch the application to the Dockerfile build strategy:
1

Choose the Dockerfile build strategy

On the application’s Settings tab, in the Build strategy section, click Update build strategy. Click Dockerfile, keep the Dockerfile path Dockerfile and the Docker context ., then click Update build strategy.
2

Deploy

Click the Overview tab, then click Deploy now under Recent deployments. Check the Branch and click Deploy.
The build log starts with 📦 Building and pushing docker image using Dockerfile..., then shows each step of your Dockerfile. Add a job as in Deploy and verify: the Worker process logs Job 1 completed, and the deployment’s Dockerfile tab shows your file. See Dockerfile.

Deploy changes

With Auto-deploy on, every push to the branch starts a deployment; otherwise, click Deploy now for each change. Each deployment builds one image and replaces the instances of both processes with it. Jobs added while the deployment runs wait in Redis until a worker takes them. See Deployments.

Deploy with the API

This sequence adds the database and the background worker of this guide to the application from Deploy Express through the Sevalla API, so an agent or a script can run it as is. APP_ID is that application, and the changes on this page are pushed to its branch. Each step names the operation, shows the curl command, and gives the matching CLI command and MCP server tool. You need an API key with the Company developer role in the SEVALLA_API_KEY environment variable (the CLI reads the same key from SEVALLA_API_TOKEN), and curl, jq and openssl. A call that fails returns status and message instead of the resource, and jq -r .id prints null: stop and read the message.
1

Look up the location and the database size

Get application (getApplicationV3) returns the application’s location in cluster_id; the database must be in the same one. List database resource types (listDatabaseResourceTypesV3) returns the database sizes; DB1 is the smallest.
CLI: sevalla apps get "$APP_ID" and sevalla resources db-types. MCP: get_application and get_platform_options.
2

Create the database

Create database (createDatabaseV3) starts creating a Valkey 9.0 database with a password you generate. List database versions (listDatabaseVersionsV3) lists the versions of a location; a Redis database works the same way with "type": "redis".
CLI: sevalla databases create --name storefront-redis --type valkey --db-version 9.0 --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name storefront --db-password "$DB_PASSWORD". MCP: create_database, which generates a password and doesn’t return it; set one you know afterwards with sevalla_api_update, resetDatabasePasswordV3 and {"new_password": "..."}.
3

Connect the database

Create internal connection (createDatabaseInternalConnectionV3) connects the database to the application over the private network. Unlike the dashboard, it adds no environment variables: a later step adds them.
CLI: sevalla databases connections internal-create "$DB_ID" --target-id "$APP_ID" --target-type app. MCP: sevalla_api_create with createDatabaseInternalConnectionV3.
4

Read the database details

Get database (getDatabaseV3) returns the database’s internal hostname and port. The loop waits until its status is ready.
CLI: sevalla databases get "$DB_ID" --json. MCP: get_database.
5

Add the environment variables

Create environment variable (createEnvironmentVariableV3) adds one variable per call. add_var sends each one as available during runtime only. These are the variables the dashboard’s internal connection adds, except REDIS_URL, which the code doesn’t read:
CLI: sevalla apps env-vars create "$APP_ID" --key REDIS_HOST --value "$(jq -r .internal_hostname <<< "$DB")", once per variable; the CLI makes each one available during the build too. MCP: set_environment_variables with the three variables in one call, each with runtime true and buildtime false.
6

Create the background worker

List process resource types (listProcessResourceTypesV3) returns the H1 size, and Create process (createProcessV3) adds the Worker process. Every process type needs scaling_strategy.
CLI: sevalla apps processes create "$APP_ID" --name Worker --type worker --resource-type "$H1_ID" --command "exec node worker.js". MCP: sevalla_api_create with createProcessV3 and the same body.
7

Deploy

Trigger deployment (triggerDeploymentV3) builds the latest commit of the branch and rolls it out.
CLI: sevalla apps deployments trigger "$APP_ID". MCP: trigger_deployment.
8

Wait for the deployment

Get deployment (getDeploymentV3) returns the deployment’s status: waiting, then inProgress, then success or failed. The loop polls every 15 seconds for up to 30 minutes and keeps polling through a failed request.
CLI: sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID". MCP: get_deployment.
9

Check the deployment

List application domains (listApplicationDomainsV3) returns the application’s domain. Add a job, then Get application runtime logs (getApplicationRuntimeLogsV3) shows what the worker printed:
The worker’s lines and the job’s state, here for job 2: with the job scheduler from Schedule repeating jobs in worker.js, the first job you add gets ID 2, not 1.
CLI: sevalla apps domains list --app-id "$APP_ID" and sevalla apps logs runtime "$APP_ID" --search "job $JOB_ID ". MCP: get_application and search_runtime_logs.