> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Run background jobs with BullMQ

> Queue background jobs in a Sevalla Redis or Valkey database with BullMQ: the web process adds jobs, a background worker runs them with retries, schedules and a clean shutdown.

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](https://docs.sevalla.com/guides/javascript/express.md#at-a-glance):

| | |
| - | - |
| Processes | `Worker` background worker (`exec node worker.js`), from the same image as the web process |
| Services | Valkey 9.0 or Redis over an internal connection |
| Size | **H1** for the worker, **DB1** for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `REDIS_HOST` | Yes | Runtime | No | Added by the internal connection: the database's internal hostname |
| `REDIS_PORT` | Yes | Runtime | No | Added by the internal connection: the database's internal port |
| `REDIS_PASSWORD` | Yes | Runtime | Yes | Added by the internal connection: the database's password |

The web process and the worker both get them. The Express application's own variables are in [its table](https://docs.sevalla.com/guides/javascript/express.md#environment-variables). 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](https://docs.sevalla.com/guides/javascript/express.md). 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](https://docs.sevalla.com/databases/create.md) 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](https://docs.sevalla.com/databases/connect.md#connect-an-application).

## Install BullMQ

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm install bullmq ioredis
```

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:

```js queue.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { Queue } from "bullmq";

export const connection = {
  host: process.env.REDIS_HOST,
  port: Number(process.env.REDIS_PORT),
  password: process.env.REDIS_PASSWORD,
};

export const emailQueue = new Queue("emails", {
  connection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: { age: 24 * 3600 },
    removeOnFail: { age: 7 * 24 * 3600 },
  },
});
```

* 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](#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:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import express from "express";
import { emailQueue } from "./queue.js";

const app = express();
const port = process.env.PORT || 3000;

app.use(express.json());

app.post("/emails", async (req, res) => {
  const job = await emailQueue.add("welcome", { to: req.body.to });
  res.status(202).json({ id: job.id });
});

app.get("/emails/:id", async (req, res) => {
  const job = await emailQueue.getJob(req.params.id);
  if (!job) {
    res.sendStatus(404);
    return;
  }
  res.json({ id: job.id, state: await job.getState(), result: job.returnvalue });
});

const server = app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

process.on("SIGTERM", () => {
  server.close(async () => {
    await emailQueue.close();
    process.exit(0);
  });
});
```

## Process jobs in a worker

Create the worker, which runs in its own process:

```js worker.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { Worker } from "bullmq";
import { connection } from "./queue.js";

// Stands in for a call to your email provider's API.
async function sendEmail(to) {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  return { sentTo: to, sentAt: new Date().toISOString() };
}

const worker = new Worker(
  "emails",
  async (job) => {
    console.log(`Sending job ${job.id} to ${job.data.to}, attempt ${job.attemptsMade + 1}`);
    return sendEmail(job.data.to);
  },
  { connection, concurrency: 5 }
);

worker.on("completed", (job) => console.log(`Job ${job.id} completed`));
worker.on("failed", (job, err) => console.log(`Job ${job?.id} failed: ${err.message}`));

process.on("SIGTERM", async () => {
  console.log("SIGTERM received, finishing active jobs");
  await worker.close();
  process.exit(0);
});
```

`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](#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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
REDIS_HOST=localhost REDIS_PORT=6379 npm start
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
REDIS_HOST=localhost REDIS_PORT=6379 node worker.js
```

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 fields](https://assets.sevalla.com/screenshots/light/app-processes-worker-create.png)

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](https://docs.sevalla.com/applications/processes.md#background-workers).

## Deploy and verify

Commit and push the changes, `package-lock.json` included: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) 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`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -X POST https://storefront-x7k2m.sevalla.app/emails -H "Content-Type: application/json" -d '{"to":"ana@example.com"}'
   ```

   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](https://docs.sevalla.com/applications/logs.md).
3. Check the job's state:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl https://storefront-x7k2m.sevalla.app/emails/1
   ```

   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.

```js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { UnrecoverableError } from "bullmq";

if (!job.data.to.includes("@")) {
  throw new UnrecoverableError("Invalid address");
}
```

## 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](https://docs.sevalla.com/applications/processes.md#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:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SIGTERM received, finishing active jobs
Job 346 completed
Job 347 completed
```

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:

```js worker.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { connection, emailQueue } from "./queue.js";

// Adds a digest job every day at 06:00 UTC. Running this again only updates the schedule.
await emailQueue.upsertJobScheduler(
  "daily-digest",
  { pattern: "0 6 * * *", tz: "UTC" },
  { name: "digest", data: { to: "team@example.com" } }
);
```

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](https://docs.sevalla.com/applications/processes.md#cron-jobs) runs a command on a schedule instead:

| | BullMQ job scheduler | Sevalla cron job |
| - | - | - |
| What runs | A job in the worker that's already running | A new instance that runs a command, such as `node digest.js`, and stops |
| Schedule | A cron pattern with seconds, or an interval with `every`, in any time zone | A 5-field cron expression in UTC or a time zone you choose |
| Failures | Retried with `attempts` and `backoff` | Retried once, and listed in the cron job's run history |
| Cost | Nothing beyond the worker | Each run, while it runs |
| Use it for | Jobs that use the queue, frequent or short tasks | Standalone scripts, such as a nightly cleanup, in an application without a worker |

## 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](https://docs.sevalla.com/applications/scaling.md).

## 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 Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM node:24-trixie-slim
WORKDIR /app
ENV NODE_ENV=production PORT=8080
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
USER 1000:1000
EXPOSE 8080
CMD ["node", "server.js"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The official Node.js 24 image on Debian 13, the line `engines.node` pins. The tag follows Node.js 24's patch releases, so each build gets its security fixes; a digest would stay on one release. |
| One stage | The project has no build step. `npm ci --omit=dev` installs only what runs, and `npm cache clean --force` drops the download cache in the same layer. |
| `package.json` and `package-lock.json` first | Docker reuses the `npm ci` layer while the lock file doesn't change, so a code change skips the install. |
| `USER 1000:1000` | Both processes run as the image's unprivileged `node` user. The application stays owned by root, so they can read it but not change it. |
| No init process | `server.js` and `worker.js` handle `SIGTERM` themselves, so Node.js shuts down cleanly as the container's first process. |
| `CMD ["node", "server.js"]` | The web process. `server.js` listens on `PORT`, which Sevalla sets at runtime; the image's default matches Sevalla's. |

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`.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
node_modules
.env*
npm-debug.log*
Dockerfile
.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](#deploy-and-verify): the `Worker` process logs `Job 1 completed`, and the deployment's **Dockerfile** tab shows your file. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## 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](https://docs.sevalla.com/applications/deployments.md).

## Deploy with the API

This sequence adds the database and the background worker of this guide to the application from [Deploy Express](https://docs.sevalla.com/guides/javascript/express.md#deploy-with-the-api) through the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md), 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](https://docs.sevalla.com/developer-tools/cli.md) command and [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool.

You need an [API key](https://docs.sevalla.com/developer-tools/api-keys.md) 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](https://api-docs.sevalla.com/v3/applications/get-application) (`getApplicationV3`) returns the application's location in `cluster_id`; the database must be in the same one. [List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) returns the database sizes; **DB1** is the smallest.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s https://api.sevalla.com/v3/applications/$APP_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .cluster_id)
DB_SIZE_ID=$(curl -s https://api.sevalla.com/v3/resources/database-resource-types \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `sevalla apps get "$APP_ID"` and `sevalla resources db-types`. MCP: `get_application` and `get_platform_options`.

**2. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) starts creating a Valkey 9.0 database with a password you generate. [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) lists the versions of a location; a Redis database works the same way with `"type": "redis"`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 24)
DB_ID=$(curl -s https://api.sevalla.com/v3/databases \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON | jq -r .id
{
  "display_name": "storefront-redis",
  "type": "valkey",
  "version": "9.0",
  "cluster_id": "$CLUSTER_ID",
  "resource_type_id": "$DB_SIZE_ID",
  "db_name": "storefront",
  "db_password": "$DB_PASSWORD"
}
JSON
)
```

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](https://api-docs.sevalla.com/v3/databases/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.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://api.sevalla.com/v3/databases/$DB_ID/internal-connections \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"target_id\": \"$APP_ID\", \"target_type\": \"app\"}" | jq .
```

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](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns the database's internal hostname and port. The loop waits until its `status` is `ready`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  [ "$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')" = ready ] && break
  sleep 10
done
DB=$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY")
```

CLI: `sevalla databases get "$DB_ID" --json`. MCP: `get_database`.

**5. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
add_var() {
  curl -s https://api.sevalla.com/v3/applications/$APP_ID/env-vars \
    -H "Authorization: Bearer $SEVALLA_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "$1" --arg value "$2" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" | jq -r .key
}
add_var REDIS_HOST "$(jq -r .internal_hostname <<< "$DB")"
add_var REDIS_PORT "$(jq -r .internal_port <<< "$DB")"
add_var REDIS_PASSWORD "$DB_PASSWORD"
```

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](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) returns the **H1** size, and [Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) adds the `Worker` process. Every process type needs `scaling_strategy`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
H1_ID=$(curl -s https://api.sevalla.com/v3/resources/process-resource-types \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[] | select(.name == "h1") | .id')
curl -s https://api.sevalla.com/v3/applications/$APP_ID/processes \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON | jq -r .display_name
{
  "display_name": "Worker",
  "type": "worker",
  "entrypoint": "exec node worker.js",
  "resource_type_id": "$H1_ID",
  "scaling_strategy": {"type": "manual", "config": {"instanceCount": 1}}
}
JSON
```

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](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) builds the latest commit of the branch and rolls it out.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/deployments \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger "$APP_ID"`. MCP: `trigger_deployment`.

**8. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/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.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

CLI: `sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment`.

**9. Check the deployment**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the application's domain. Add a job, then [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) shows what the worker printed:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_URL=https://$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/domains \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.data[] | select(.is_primary) | .name')
JOB_ID=$(curl -s -X POST $APP_URL/emails -H "Content-Type: application/json" -d '{"to":"ana@example.com"}' | jq -r .id)
sleep 10
curl -s -G https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs --data-urlencode "search=job $JOB_ID " \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
curl -s $APP_URL/emails/$JOB_ID
```

The worker's lines and the job's state, here for job `2`: with the job scheduler from [Schedule repeating jobs](#schedule-repeating-jobs) in `worker.js`, the first job you add gets ID `2`, not `1`.

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Sending job 2 to ana@example.com, attempt 1
Job 2 completed
{"id":"2","state":"completed","result":{"sentTo":"ana@example.com","sentAt":"2026-10-30T05:12:27.820Z"}}
```

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`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.