Skip to content

Recipe: Jobs in Node.js on Azure Container Apps

Use this recipe to build a Node.js console Job, turn it into a one-message Service Bus consumer, and add a dedup table pattern for safe retries.

Prerequisites

  • Azure Container Apps environment and registry
  • Azure Service Bus namespace and queue for the event-driven example
  • Node.js 20+, Docker, and Azure CLI
export RG="rg-aca-node-prod"
export ENVIRONMENT_NAME="aca-env-node-prod"
export ACR_NAME="acrnodeprod"
export JOB_NAME="job-node-manual"
export EVENT_JOB_NAME="job-node-servicebus"
export SERVICEBUS_NAMESPACE="sb-aca-prod"
export SERVICEBUS_QUEUE="orders"

What You'll Build

  • a manual Node.js Job with a console entrypoint
  • an event-driven Service Bus consumer that receives one message and exits
  • a dedup-table example you can port to Azure SQL or PostgreSQL for production

Steps

flowchart TD
    A[Write Node.js worker] --> B[Build image]
    B --> C[Create manual job]
    C --> D[Run ad-hoc execution]
    D --> E[Switch to Service Bus consumer]
    E --> F[Add dedup table]

1. Create a manual Node.js Job

package.json:

{
  "name": "aca-node-job",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@azure/identity": "^4.4.1",
    "@azure/service-bus": "^7.9.4",
    "sqlite3": "^5.1.7"
  }
}

index.js:

const execution = process.env.CONTAINER_APP_JOB_EXECUTION_NAME || "local";
const task = process.env.TASK_NAME || "reconcile-orders";

console.log(JSON.stringify({ event: "job-start", execution, task }));
console.log(JSON.stringify({ event: "job-work", message: "processing batch" }));
console.log(JSON.stringify({ event: "job-end", status: "Succeeded" }));

Dockerfile:

FROM node:20-alpine

WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY index.js .

CMD ["npm", "start"]

Deploy and trigger the Job:

az acr build \
  --registry "$ACR_NAME" \
  --image "node-jobs/manual:v1" \
  --file "Dockerfile" \
  "."

az containerapp job create \
  --name "$JOB_NAME" \
  --resource-group "$RG" \
  --environment "$ENVIRONMENT_NAME" \
  --trigger-type "Manual" \
  --image "$ACR_NAME.azurecr.io/node-jobs/manual:v1" \
  --replica-timeout 600 \
  --replica-retry-limit 1

az containerapp job start \
  --name "$JOB_NAME" \
  --resource-group "$RG"

2. Turn it into a one-message Service Bus consumer

Replace index.js with:

import { DefaultAzureCredential } from "@azure/identity";
import { ServiceBusClient } from "@azure/service-bus";

const fullyQualifiedNamespace = `${process.env.SERVICEBUS_NAMESPACE}.servicebus.windows.net`;
const queueName = process.env.SERVICEBUS_QUEUE;

const client = new ServiceBusClient(fullyQualifiedNamespace, new DefaultAzureCredential());
const receiver = client.createReceiver(queueName);

try {
  const messages = await receiver.receiveMessages(1, { maxWaitTimeInMs: 15000 });
  if (messages.length === 0) {
    console.log(JSON.stringify({ event: "empty-queue" }));
  } else {
    const message = messages[0];
    console.log(JSON.stringify({ event: "message-received", messageId: message.messageId }));
    await receiver.completeMessage(message);
    console.log(JSON.stringify({ event: "message-completed", messageId: message.messageId }));
  }
} finally {
  await receiver.close();
  await client.close();
}

Create the event-driven Job:

az acr build \
  --registry "$ACR_NAME" \
  --image "node-jobs/servicebus:v1" \
  --file "Dockerfile" \
  "."

az containerapp job create \
  --name "$EVENT_JOB_NAME" \
  --resource-group "$RG" \
  --environment "$ENVIRONMENT_NAME" \
  --trigger-type "Event" \
  --image "$ACR_NAME.azurecr.io/node-jobs/servicebus:v1" \
  --scale-rule-name "orders-queue" \
  --scale-rule-type "azure-servicebus" \
  --scale-rule-metadata "queueName=$SERVICEBUS_QUEUE" "messageCount=1" "namespace=$SERVICEBUS_NAMESPACE.servicebus.windows.net" \
  --env-vars SERVICEBUS_NAMESPACE="$SERVICEBUS_NAMESPACE" SERVICEBUS_QUEUE="$SERVICEBUS_QUEUE"

3. Add a dedup table

For a runnable demo, add SQLite. In production, move the same insert-if-absent pattern to a shared durable database.

import sqlite3 from "sqlite3";

function shouldProcess(messageId) {
  return new Promise((resolve, reject) => {
    const db = new sqlite3.Database("/tmp/dedup.db");
    db.serialize(() => {
      db.run("create table if not exists processed_messages (message_id text primary key)");
      db.run("insert or ignore into processed_messages(message_id) values (?)", [messageId], function (error) {
        if (error) {
          reject(error);
          return;
        }
        resolve(this.changes === 1);
      });
    });
  });
}

Use it before processing:

if (await shouldProcess(message.messageId)) {
  console.log(JSON.stringify({ event: "process-message", messageId: message.messageId }));
} else {
  console.log(JSON.stringify({ event: "duplicate-message", messageId: message.messageId }));
}

Verification

az containerapp job execution list \
  --name "$JOB_NAME" \
  --resource-group "$RG" \
  --output table

az containerapp job execution list \
  --name "$EVENT_JOB_NAME" \
  --resource-group "$RG" \
  --output table

Next Steps / Clean Up

  • Move the dedup table to a shared durable store.
  • Add Application Insights correlation IDs to log payloads.
  • Review Jobs Operations before production rollout.

See Also

Sources