Logic Apps Standard is not “Consumption with a different SKU.” It is a different runtime — the single-tenant Azure Functions host running the Logic Apps engine as an extension — and that distinction drives almost every architectural decision you will make. You get a deployable package of code-like artifacts instead of a portal-bound resource, a dedicated compute plane you can drop into a virtual network, built-in connectors that run in-process, and a flat hosting price instead of per-action billing. The trade is that you now own things the Consumption tier hid from you: the storage account behind run history, the VNet plumbing, the deployment pipeline, and the integration account lifecycle.
This guide builds a production-grade Standard logic app the way it survives an enterprise: stateful and stateless workflows side by side, locked behind private endpoints, exchanging EDI with trading partners, and shipped through Bicep with deployment slots. Examples use the Azure Functions Core Tools (func), the az logicapp and az functionapp CLIs, and the Standard project file layout.
1. Standard vs Consumption: the single-tenant runtime and pricing model
In Consumption, each logic app is one workflow, defined in ARM, billed per action execution, sharing a multi-tenant runtime you do not control. Standard inverts all of that:
- One logic app resource hosts many workflows. Each workflow is a
workflow.jsonfile in its own folder. The resource is the unit of deployment, scaling, networking, and identity. - It runs on the single-tenant Functions host. Connectors, the engine, and your workflows execute on compute you provision — a Workflow Standard (WS1/WS2/WS3) plan or App Service plan, or App Service Environment v3 for full isolation.
- Billing is by allocated compute (vCPU/memory), not per action. A chatty workflow that fires millions of cheap actions is dramatically cheaper here; a workflow that runs ten times a day is cheaper on Consumption.
- Built-in (service-provider) connectors run in-process, so common targets — Service Bus, Blob, SQL, Event Hubs, Cosmos DB — avoid the per-call API-connection model entirely.
A bare-bones provisioning path, App Service plan plus the logic app:
RG=rg-integration-prod
LOC=westeurope
PLAN=asp-logicapps-prod
APP=la-orders-prod
SA=stlaordersprod$RANDOM # storage backs run history + content
az group create -n $RG -l $LOC
az storage account create -n $SA -g $RG -l $LOC \
--sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2 \
--allow-blob-public-access false
# Workflow Standard plan (elastic) for single-tenant Logic Apps
az functionapp plan create -n $PLAN -g $RG -l $LOC \
--sku WS1 --is-linux false
az logicapp create -n $APP -g $RG -l $LOC \
--plan $PLAN --storage-account $SA \
--functions-version 4
The
az logicappcommand group is a thin wrapper overaz functionapp. Many operations you cannot find underaz logicappexist underaz functionappand work fine, because a Standard logic app is a function app with a Logic Apps extension bundle and akindoffunctionapp,workflowapp.
The local project that this resource deploys is just a folder. func init with the workflow worker, or the VS Code “Azure Logic Apps (Standard)” extension, scaffolds:
my-logic-app/
host.json # runtime settings for the whole resource
local.settings.json # local-only app settings (do NOT deploy secrets)
connections.json # managed + service-provider connection refs
parameters.json # workflow parameters (environment-substitutable)
Orders-Intake/
workflow.json # one workflow definition
EDI-Inbound/
workflow.json
workflow-designtime/ # design-time host used by the designer only
host.json
local.settings.json
The host.json pins the extension bundle that carries the Logic Apps runtime and all built-in connectors:
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle.Workflows",
"version": "[1.*, 2.0.0)"
}
}
2. Stateful vs stateless workflows and run-history storage design
Every workflow declares a kind: Stateful or Stateless. This is the single most consequential per-workflow choice you make.
Stateful workflows persist every run, every action input/output, and trigger history to external storage (Azure Storage tables and blobs behind AzureWebJobsStorage). You get durable run history, full input/output inspection in the portal, resubmit, and the ability to survive a host restart mid-run. Long-running, asynchronous, or human-in-the-loop workflows must be stateful.
Stateless workflows run entirely in memory. No persisted run history, no mid-run durability, far lower latency and higher throughput. They are ideal for short, synchronous request/response paths — a webhook that validates and forwards in under a second. The constraints are real: no built-in Until loop durability across restarts, no chunking, and run history is off by default (you can enable it for debugging via Workflows.<name>.OperationOptions = WithStatelessRunHistory, at a throughput cost).
A minimal stateful definition header:
{
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"triggers": { },
"actions": { },
"outputs": { }
},
"kind": "Stateful"
}
Run-history storage design is your responsibility now. The storage account is on the hot path of every stateful run; treat it as a first-class dependency:
- Use a dedicated storage account per logic app (or per environment) so run-history I/O does not contend with application data.
- Keep it in the same region as the plan. Cross-region storage adds latency to every action checkpoint.
- Plan retention. Stateful run history accumulates in storage and counts toward cost. Set retention with the
Runtime.Backend.Run.MaxRunHistoryRetentionInDayshost setting and let the engine prune:
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle.Workflows",
"version": "[1.*, 2.0.0)"
},
"extensions": {
"workflow": {
"settings": {
"Runtime.Backend.Run.MaxRunHistoryRetentionInDays": "90"
}
}
}
}
A useful default: make the fast synchronous edges stateless and the orchestration core stateful, and have the stateless front door call the stateful workflow. You get low-latency intake and durable processing without paying the storage tax on every health check.
3. VNet integration, private endpoints, and access restrictions
Enterprise Standard deployments are almost always private. There are two independent traffic directions, and you configure them separately.
Outbound (your workflow reaching private resources) uses regional VNet integration — the same mechanism as App Service. Outbound calls to a private SQL, Service Bus, or storage endpoint route through a delegated subnet:
VNET=vnet-integration
SUBNET=snet-logicapps # delegated to Microsoft.Web/serverFarms, /26 or larger
az functionapp vnet-integration add -g $RG -n $APP \
--vnet $VNET --subnet $SUBNET
# Force all outbound traffic through the VNet (not just RFC1918)
az functionapp config appsettings set -g $RG -n $APP \
--settings WEBSITE_VNET_ROUTE_ALL=1
Inbound (clients reaching your workflow triggers) uses a private endpoint on the logic app, plus public-access lockdown:
az network private-endpoint create -g $RG -n pe-$APP \
--vnet-name $VNET --subnet snet-private-endpoints \
--private-connection-resource-id $(az logicapp show -g $RG -n $APP --query id -o tsv) \
--group-id sites --connection-name conn-$APP
az functionapp config access-restriction set -g $RG -n $APP \
--default-action Deny
The storage account is the part teams forget. A Standard logic app cannot start if it cannot reach its own run-history storage. When you lock the storage account behind private endpoints, you must also tell the runtime to mount its content share over the VNet. The modern control is the site property vnetContentShareEnabled (which superseded the WEBSITE_CONTENTOVERVNET=1 app setting). You need private endpoints for all four storage sub-resources — blob, file, table, and queue:
for SUB in blob file table queue; do
az network private-endpoint create -g $RG -n pe-$SA-$SUB \
--vnet-name $VNET --subnet snet-private-endpoints \
--private-connection-resource-id $(az storage account show -g $RG -n $SA --query id -o tsv) \
--group-id $SUB --connection-name conn-$SA-$SUB
done
# Mount content share over the VNet (required for private storage)
az resource update -g $RG -n $APP \
--resource-type "Microsoft.Web/sites" \
--set properties.vnetContentShareEnabled=true
When you secure storage this way, every app sharing that account must use the same
vnetContentShareEnabledvalue, and you still need the corresponding private DNS zones (privatelink.{blob,file,table,queue}.core.windows.net) linked to the VNet, or name resolution fails before networking does.
4. Built-in vs managed connectors and authentication patterns
Standard exposes two connector families, and choosing correctly is both a cost and a networking decision.
| Aspect | Built-in (service provider) | Managed (API connection) |
|---|---|---|
| Where it runs | In-process on your plan | Multi-tenant connector infra |
| Networking | Honors VNet integration | Egresses from Microsoft, not your VNet |
| Config lives in | connections.json (serviceProviderConnections) |
connections.json (managedApiConnections) + ARM resource |
| Cost | Included in plan compute | Per-call execution billing |
| Auth | App settings / managed identity | Connection-level auth, often a consent grant |
Prefer built-in connectors for anything in your VNet. A managed connector cannot reach a private endpoint, because its traffic originates from Microsoft’s connector service, not your integrated subnet. A built-in Service Bus or SQL connector runs on your plan and respects WEBSITE_VNET_ROUTE_ALL.
Built-in connections are pure config in connections.json, with secrets pulled from app settings:
{
"serviceProviderConnections": {
"serviceBus": {
"parameterValues": {
"fullyQualifiedNamespace": "@appsetting('SB_NAMESPACE')"
},
"serviceProvider": { "id": "/serviceProviders/serviceBus" },
"displayName": "Orders namespace"
}
}
}
For authentication, use managed identity end to end. Assign a system- or user-assigned identity to the logic app and grant it data-plane RBAC on the targets — no connection strings, no shared keys to rotate:
az logicapp identity assign -g $RG -n $APP
PRINCIPAL=$(az logicapp identity show -g $RG -n $APP --query principalId -o tsv)
SB_ID=$(az servicebus namespace show -g $RG -n ns-orders --query id -o tsv)
az role assignment create --assignee $PRINCIPAL \
--role "Azure Service Bus Data Receiver" --scope $SB_ID
The built-in connector then authenticates with the identity by referencing it in the connection’s authentication block, so the only thing in local.settings.json is the fully qualified namespace, never a key.
5. B2B messaging: AS2, X12, EDIFACT, and integration accounts
This is where Standard genuinely diverges from Consumption, and where a lot of stale documentation will mislead you.
In Standard, AS2 (v2), X12, and EDIFACT are built-in operations that run in-process — encode, decode, and the AS2 send/receive pipeline all execute on your plan. But the artifacts those operations need — trading partners, agreements, schemas, maps, and certificates — still live in an integration account. The critical change: in Standard, the integration account does not need to be linked to the logic app resource. You reference it, but the old “link the integration account to the logic app” step from Consumption is gone. The integration account must still be in the same subscription and region as the logic app.
Provision the integration account and load artifacts:
az resource create -g $RG -n ia-edi-prod \
--resource-type Microsoft.Logic/integrationAccounts \
--location $LOC \
--properties '{"sku":{"name":"Standard"}}'
# Upload an X12 schema (artifacts: schemas, partners, agreements, certs)
az logic integration-account schema create \
-g $RG --integration-account ia-edi-prod \
--schema-name X12_850_Schema \
--content-type application/xml \
--schema-type Xml \
--input-path ./artifacts/X12_00401_850.xsd
Pick the SKU deliberately. The Free tier is for dev only. Basic holds artifacts but does not support agreements (so no real X12/EDIFACT trading). Standard supports full agreements and a large artifact count. B2B tracking for Standard workflows requires a Premium-level integration account — that is the tier gate for getting transaction-level tracking surfaced via Azure Data Explorer.
A decode step in a workflow references the agreement and validates structure, control numbers, and (for AS2) MIC and signatures:
{
"Decode_X12_message": {
"type": "ServiceProvider",
"inputs": {
"parameters": {
"message": "@triggerBody()",
"agreementName": "Contoso-to-Fabrikam-850"
},
"serviceProviderConfiguration": {
"serviceProviderId": "/serviceProviders/x12",
"operationId": "decodeX12Message",
"connectionName": "x12"
}
}
}
}
The decode action returns the parsed payload, a control-number ack disposition, and a generated 997 (X12) or CONTRL (EDIFACT) acknowledgment you route back to the partner. Wire the functional ack into your error path so a partner gets a negative ack on a bad interchange rather than silence.
6. Error handling, retry policies, and dead-letter strategies
Logic Apps gives you three layers of failure control. Use all three.
1. Action-level retry policy. Every action supports a retry policy. Default is exponential, four retries. Tune it explicitly on anything that touches a flaky dependency, and set it to none on non-idempotent operations you do not want re-attempted:
{
"Call_partner_endpoint": {
"type": "Http",
"inputs": {
"method": "POST",
"uri": "@parameters('partnerUrl')",
"retryPolicy": {
"type": "exponential",
"count": 4,
"interval": "PT10S",
"maximumInterval": "PT1H",
"minimumInterval": "PT10S"
}
}
}
}
2. Scopes with runAfter for try/catch. Wrap a body of actions in a Scope, then add a handler scope that runs only when the first one fails, via runAfter statuses. This is the canonical Logic Apps try/catch:
{
"Try": { "type": "Scope", "actions": { "Process_order": { } } },
"Catch": {
"type": "Scope",
"runAfter": { "Try": ["Failed", "TimedOut"] },
"actions": {
"Send_to_deadletter": {
"type": "ServiceProvider",
"inputs": {
"parameters": { "entityName": "orders-deadletter", "message": "@result('Try')" },
"serviceProviderConfiguration": {
"serviceProviderId": "/serviceProviders/serviceBus",
"operationId": "sendMessage",
"connectionName": "serviceBus"
}
}
}
}
}
}
@result('Try') returns the full action results inside the failed scope — inputs, outputs, status, error — which is exactly what you want to capture for a dead-letter record.
3. Dead-letter the message, not just the run. For event-driven intake (Service Bus, Event Hubs), let the broker’s native dead-letter queue do its job: do not auto-complete a message until processing succeeds, so a thrown exception abandons it back to the queue and, after max delivery count, lands it in the DLQ. Pair that with a separate “DLQ drain” workflow that reads the dead-letter subqueue, enriches with the failure reason, and pushes to a triage topic. The result: a poison EDI interchange parks itself instead of hot-looping your consumer.
7. CI/CD with deployment slots, parameters, and ARM/Bicep packaging
Standard logic apps deploy as a zip of the project folder — the same artifact you run locally. That makes CI/CD genuinely code-like.
Parameterize for environments. Keep environment-specific values out of workflow.json. Use parameters.json for workflow parameters and app settings for connection endpoints, then substitute at deploy time. Never commit local.settings.json secrets.
Provision infrastructure with Bicep, identity-first:
param location string = resourceGroup().location
param appName string
param planId string
param storageName string
resource logicApp 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
kind: 'functionapp,workflowapp'
identity: { type: 'SystemAssigned' }
properties: {
serverFarmId: planId
vnetContentShareEnabled: true
siteConfig: {
vnetRouteAllEnabled: true
appSettings: [
{ name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' }
{
name: 'AzureWebJobsStorage'
value: 'DefaultEndpointsProtocol=https;AccountName=${storageName};AccountKey=${listKeys(resourceId('Microsoft.Storage/storageAccounts', storageName), '2023-01-01').keys[0].value};EndpointSuffix=core.windows.net'
}
{ name: 'APP_KIND', value: 'workflowApp' }
]
}
}
}
Deploy code with slots for zero-downtime cutover. Push to a staging slot, validate, then swap:
# one-time: create the staging slot
az functionapp deployment slot create -g $RG -n $APP --slot staging
# in the pipeline: zip-deploy the built project to staging
az functionapp deployment source config-zip \
-g $RG -n $APP --slot staging --src ./output/app.zip
# smoke test the staging hostname here, then swap into production
az functionapp deployment slot swap -g $RG -n $APP --slot staging --target-slot production
Two slot caveats specific to Standard: mark connection-string and endpoint app settings as slot-sticky (deployment-slot settings) so a swap does not move staging credentials into production, and remember that a slot needs its own VNet integration — it is not inherited from production, so integrate the slot’s subnet or its outbound calls leave the VNet.
8. Monitoring with Application Insights and run-time telemetry
Enable Application Insights at creation; the runtime emits structured telemetry for triggers, actions, and the host. Wire it through app settings:
AI_CONN=$(az monitor app-insights component show -g $RG -a ai-logicapps --query connectionString -o tsv)
az functionapp config appsettings set -g $RG -n $APP \
--settings APPLICATIONINSIGHTS_CONNECTION_STRING="$AI_CONN"
Workflow runs surface as requests, and individual action executions as dependencies and traces, each tagged with the workflow and run identifiers. This KQL pulls failed actions in the last day with their error, grouped by workflow:
traces
| where timestamp > ago(1d)
| extend wf = tostring(customDimensions["resource_workflowName"])
| extend action = tostring(customDimensions["actionName"])
| extend status = tostring(customDimensions["status"])
| where status == "Failed"
| summarize failures = count() by wf, action, message
| order by failures desc
For latency, chart the run duration distribution to catch a workflow whose p95 is creeping toward the host timeout:
requests
| where timestamp > ago(7d)
| where name has "workflow"
| summarize p50 = percentile(duration, 50),
p95 = percentile(duration, 95),
p99 = percentile(duration, 99)
by bin(timestamp, 1h), name
| render timechart
Set a metric alert on host health (Workflow Action/Trigger Failure Rate, or the Http5xx and MemoryWorkingSet platform metrics on the plan) so you find a degraded plan before a partner does.
Enterprise scenario
A logistics platform team ran EDI intake for retail trading partners on Logic Apps Consumption. As volume grew, two things broke at once: per-action billing on high-fan-out 850/856 processing turned the monthly bill into a line item leadership noticed, and a new security baseline mandated that the SQL database holding order state be private-endpoint-only — which the Consumption tier’s managed SQL connector could not reach, because its traffic originates outside the customer’s network.
They migrated to Logic Apps Standard on a WS2 plan inside the platform’s hub-spoke VNet. The win was structural: the built-in SQL and Service Bus connectors run in-process and honor VNet integration, so order writes flowed over the private endpoint with no public exposure, and flat compute billing made the high-action EDI parsing essentially free at the margin. They kept their existing integration account (now unlinked, per the Standard model) for partner agreements and X12 schemas, and moved AS2 decode in-process.
The subtle failure they hit during cutover: the logic app would not start in the locked-down subscription. The storage account had been put behind private endpoints to meet the same baseline, but only the blob and file sub-resources had endpoints — the team had followed an App Service runbook. The Logic Apps runtime also needs table and queue storage for run history, and without vnetContentShareEnabled the content share would not mount. The fix was two lines of infrastructure:
// All four storage sub-resources need private endpoints for a Standard logic app
var storageGroups = [ 'blob', 'file', 'table', 'queue' ]
resource pe 'Microsoft.Network/privateEndpoints@2023-11-01' = [for g in storageGroups: {
name: 'pe-${storageName}-${g}'
location: location
properties: {
subnet: { id: privateEndpointSubnetId }
privateLinkServiceConnections: [{
name: 'conn-${g}'
properties: {
privateLinkServiceId: storageAccountId
groupIds: [ g ]
}
}]
}
}]
With all four endpoints, the matching privatelink DNS zones linked, and vnetContentShareEnabled=true on the site, the runtime mounted and the workflows started. Post-migration, EDI processing cost dropped by roughly two-thirds and the private-network audit finding closed.
Verify
Confirm the deployment actually behaves as designed, not just that the resources exist:
# 1. The resource is a workflow app, not a plain function app
az logicapp show -g $RG -n $APP --query kind -o tsv
# expect: functionapp,workflowapp
# 2. Managed identity is assigned and has its data-plane role
az logicapp identity show -g $RG -n $APP --query principalId -o tsv
# 3. Outbound routes through the VNet
az functionapp config appsettings list -g $RG -n $APP \
--query "[?name=='WEBSITE_VNET_ROUTE_ALL'].value" -o tsv # expect: 1
# 4. Content share mounts over the VNet (private storage)
az resource show -g $RG -n $APP --resource-type "Microsoft.Web/sites" \
--query properties.vnetContentShareEnabled -o tsv # expect: true
# 5. All four storage private endpoints exist
az network private-endpoint list -g $RG \
--query "[?contains(name,'$SA')].name" -o tsv # expect 4 entries
Then, in Application Insights, run a stateful workflow and confirm a requests record appears with matching dependencies for each action; trigger a known-bad EDI interchange and confirm the message lands in the dead-letter queue and a negative functional ack is generated. If the run history shows the run but no telemetry, your APPLICATIONINSIGHTS_CONNECTION_STRING is missing or wrong.