Deploying to Azure

To deploy to Azure, set provider: "azure" in your config and run laranja deploy. Your app, its @Cron jobs, and its @Queue consumers land in your own Azure subscription as Function Apps — the app code is identical to the AWS path. Only the back half of laranja changes.

What's supported today: Express apps with HTTP, crons (@Cron / cron()), queues (@Queue / queue(), backed by Azure Storage Queues — FIFO is AWS-only), and environment variables. http() is optional — a crons/queues-only app (no HTTP endpoint) deploys just fine.

NestJS apps get the same feature set, including class-based @Cron / @Queue providers resolved through a workers() dependency-injection root. FIFO queues remain AWS-only.

Prerequisites

  • An Azure subscription and a resource group that already exists — laranja deploys into a group, it doesn't create one. Note the subscription id (az account show --query id -o tsv) and the group name.
  • Azure credentials on the standard chain (DefaultAzureCredential): env vars, a managed identity, or az login. Locally, az login is enough; in CI, set a service principal via the AZURE_* environment variables.
  • A region that offers Flex Consumption and is accepting new customers — westus2 is a safe default. laranja runs your app on the Azure Functions Flex Consumption plan.

You do not need Bicep, ARM templates, or the Azure Functions Core Tools — laranja synthesizes and submits the ARM deployment for you.

Configure

Point your config at Azure and add the azure block:

// laranja.config.ts
import type { LaranjaConfig } from "@alzulejos/laranja-decorators";

const config: LaranjaConfig = {
  name: "my-api",
  projectId: "proj_…",
  provider: "azure",
  region: "westus2",
  azure: {
    subscriptionId: "00000000-0000-0000-0000-000000000000",
    resourceGroup: "my-existing-group",
  },
  env: { LOG_LEVEL: "info" },
};

export default config;

Or run laranja init, choose Azure when prompted, and it fills in the subscription, group, and region for you.

See azure in the config reference for the field details.

Deploy

Same commands as everywhere else:

npx laranja deploy

laranja provisions the infrastructure with an ARM deployment, then publishes your app; when it finishes you get a public https://‹app›.azurewebsites.net URL. plan, logs, and destroy all work against Azure too.

Environment variables

Environment variables work exactly as documented there — the static env map, code-discovered env("…") values, --strict, and the secrets caveat all carry over unchanged. The only Azure difference: they land in the Function App's application settings rather than a Lambda's environment, and are read through process.env the same way.

Crons

Cron jobs work on Azure — declare them with @Cron or cron() exactly as on AWS. The difference is structural: Azure hosts one Function App containing many functions, so each cron becomes a timer-triggered function inside that same app rather than its own isolated resource. Your HTTP proxy and every cron are distinct functions sharing the app's compute, scaling, and identity.

Schedules are lowered to Azure's NCRONTAB format and stored as application settings, so changing a schedule is a config update rather than a repackage. The portable rate() / every() builders work unchanged, and a raw cron(...) expression is translated for you.

A few AWS-specific cron options don't map to an Azure timer and are ignored with a warning — the deploy still succeeds:

  • dlq, retryAttempts, maxEventAge come from Lambda's async-invoke model; an Azure timer has no queued event to retry, age out, or dead-letter. For at-least-once delivery with dead-lettering, reach for a queue rather than a timer.
  • Per-cron timezone — Azure applies one timezone per Function App, so the first cron's timezone applies app-wide and a conflicting one warns.

Queues

Queues work on Azure — declare a consumer with @Queue or queue() and produce with getQueue().send(), the same code as on AWS. Each queue becomes an Azure Storage Queue plus a queue-triggered function inside the one Function App (like crons, the consumer is a function in the shared app, not its own isolated resource). Producing needs no setup: the app's managed identity is granted access to the storage account, so getQueue("emails").send({ … }) just works — no connection string, no SAS.

The one real difference is FIFO. AWS SQS offers true FIFO queues (ordered, deduplicated); Azure Storage Queues do not — they're best-effort ordering with at-least-once delivery and no deduplication. laranja won't silently downgrade that guarantee, so a fifo: true queue (or a .fifo name) is rejected at plan/deploy time on Azure with a clear message. Use a standard queue, or deploy that workload to AWS. (True FIFO on Azure means Service Bus, which is a future option.)

Dead-lettering

dlq works, by a different route than on AWS. A Storage Queue trigger can't dead-letter to a queue you name — the host always moves a message that fails repeatedly to an automatic ‹queue›-poison queue. That's a real queue, though, so laranja binds an extra trigger on it that drains into the consumer you declared as the dlq. Your handler receives the failures; only the queue they physically pass through differs.

resources: {
  emails: { dlq: { queue: "emailsDLQ", maxReceiveCount: 3 } },
}

On Azure that deploys emails, emailsDLQ, and emails-poison, with emailsDLQ's consumer reading both its own queue and emails-poison. emailsDLQ stays a normal queue you can send to directly.

Two limits worth knowing:

  • One dlq per queue. If two queues name the same dlq, laranja won't wire it up and warns instead — Azure gives each queue its own poison queue, and silently delivering one source's failures while dropping the other's would be worse than saying so. Give each queue its own dlq target.
  • maxReceiveCount is per Function App, not per queue (it's the host-wide maxDequeueCount). Queues in the same app share one value; if two disagree, the first wins and the other is warned about. Since each workers() root gets its own app, roots can carry different values.

A few AWS-specific queue options don't map to a Storage Queue trigger and are ignored with a warning — the deploy still succeeds:

  • visibilityTimeout, maxBatchingWindow, reportBatchItemFailures, messageRetention are SQS/event-source knobs with no per-queue Storage Queue equivalent, and batchSize is a host-wide setting on Azure (not per-queue).

What gets deployed

A single Function App on the Flex Consumption plan hosts your app — the HTTP proxy (if you declared http()), plus any crons and queue consumers, all as functions in that same app — alongside the resources it needs, inside your resource group:

  • a Function App (Microsoft.Web/sites) + its Flex Consumption plan, hosting your HTTP proxy (when present), one timer function per cron, and one queue-triggered function per queue (crons and queues add no compute of their own — they're functions inside this app). A crons/queues-only app has no HTTP function; the app still gets a hostname, but nothing serves on it,
  • a storage account for the deployment package and your queues (Microsoft.Storage/…/queueServices/queues, one per declared queue),
  • Application Insights + a Log Analytics workspace that back laranja logs.

Everything is named after your name and stage, and torn down together by destroy.

NestJS workers on Azure

If you use class-based @Cron / @Queue providers, one detail differs from AWS and is worth knowing when you read your metrics.

On Azure a function is defined by its trigger — one function, one trigger — so a workers() root with five crons becomes five functions, not one. On AWS that same root becomes a single Lambda that routes internally.

That isn't a downside. All those functions live in the one Function App and share a single process, so laranja builds each root's DI container once and every trigger belonging to it reuses that container — the same saving the AWS worker Lambda gets by consolidating. You also get finer-grained telemetry than on AWS: each cron and queue shows up as its own function in Application Insights and in laranja logs, rather than being pooled under one worker.

Declaring several roots still keeps them apart: a trigger in one root never boots another root's module. The one thing you don't get, unlike AWS's separate worker Lambdas, is process isolation — all your functions share the app's process and memory.