Azure Apps Autopilot #2 – Deployment Script

A one-click provisioning flow that still needs a separate GitHub Actions run afterward to actually deploy the app is only half a solution, and I flagged that gap in an earlier autopilot piece. The missing piece was app deployment itself, since Bicep alone is declarative and has no native way to run an imperative deployment step. Azure’s answer to that gap is the Deployment Scripts resource, and this walkthrough uses it to fold app deployment and API registration into the same Bicep pipeline that provisions the infrastructure.

The scenario here is a small microservices setup, Azure API Management as the gateway in front of an Azure Functions API, provisioned and wired together end to end from a single Deploy to Azure button, no CI/CD pipeline required afterward.

The Shape of the Problem

Provisioning the Function App and APIM instance with Bicep is the easy part, these are ordinary ARM resources with well understood modules. The hard part is that APIM cannot register a Function App’s API until the Function App is actually deployed and running, and app deployment is not something declarative Bicep resources can express on their own.

Minimal microservices setup: APIM as the gateway in front of an Azure Functions API.
Minimal microservices setup: APIM as the gateway in front of an Azure Functions API.

Without deployment scripts, the natural sequence is: provision Function App resources, provision APIM resources, deploy the function code through some separate pipeline, then register the deployed API in APIM. That third step is the one that breaks a pure Bicep-only flow, since it is inherently imperative, not a resource declaration.

Function App and APIM resources provisioned, before the app itself has been deployed or registered.
Function App and APIM resources provisioned, before the app itself has been deployed or registered.

Bicep’s Deployment Script Resource

Deployment Scripts close this gap by letting Azure Resource Manager run an actual PowerShell or Bash script as one step inside an otherwise declarative Bicep deployment. Under the hood, this spins up a short-lived Azure Container Instance and a storage account to execute the script, then tears both down afterward.

resource ds 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
  name: 'my-deployment-script'
  location: resourceGroup().location
  kind: 'AzureCLI'
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '<user-assigned-identity-id>': {}
    }
  }
  properties: {
    azCliVersion: '2.33.1'
    containerSettings: {
      containerGroupName: 'my-container-group'
    }
    environmentVariables: [
      { name: 'RESOURCE_NAME', value: '<resource-name>' }
    ]
    primaryScriptUri: '<bash-script-url>'
    retentionInterval: 'P1D'
  }
}

A few details here matter more than they look. The identity block uses a user-assigned managed identity rather than any embedded credential, since the script needs to authenticate to Azure to run CLI commands, and a managed identity is the only way to do that without a secret sitting in the Bicep file itself. The azCliVersion is pinned deliberately rather than left to float to latest, Azure’s own guidance recommends staying within about 30 days of the current release rather than always pulling the newest CLI version, since a brand new CLI release landing unexpectedly mid script run is a bad time to discover a breaking change. And retentionInterval controls how long the underlying container and storage resources hang around afterward for debugging, before Azure cleans them up automatically.

The Bash Script Doing the Actual Work

The script itself does two jobs: pull the function app’s build artifact from a GitHub release, and deploy it to the already-provisioned Function App resource.

# Get artifacts from GitHub
urls=$(curl -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/repos/devkimchi/APIM-OpenAPI-Sample/releases/latest | \
  jq '.assets[] | { name: .name, url: .browser_download_url }')
 
apizip=$(echo $urls | jq 'select(.name == "api.zip") | .url' -r)
 
# Deploy the function app
az functionapp deploy \
  -g rg-$RESOURCE_NAME \
  -n fncapp-$RESOURCE_NAME-api \
  --src-url $apizip \
  --type zip

RESOURCE_NAME here is the same environment variable declared in the deploymentScripts resource, which is what lets one script work correctly for any resource name a user types into the Deploy to Azure wizard, rather than hardcoding names that only work for one specific deployment.

Once the function app is actually running, the same script kicks off a second Azure CLI deployment to register the API in APIM, this time pointing at a compiled ARM template URL rather than a Bicep file directly.

# Register the deployed API in APIM
az deployment group create \
  -n ApiManagement_Api \
  -g rg-$RESOURCE_NAME \
  -u https://raw.githubusercontent.com/devkimchi/APIM-OpenAPI-Sample/main/Resources/provision-apimanagementapi.json \
  -p name=$RESOURCE_NAME

That -u flag pointing at a URL only accepts a compiled ARM JSON template, not a raw .bicep file, which is an easy mistake to make the first time you try this, since every other step in the workflow has been working with Bicep source directly. Compile it ahead of time with az bicep build and publish the resulting JSON somewhere publicly reachable, a raw GitHub URL works fine for this.

Wiring the Ordering Together in main.bicep

The orchestrating main.bicep file is where the actual sequencing gets enforced, using dependsOn to guarantee APIM and the Function App are both fully provisioned before the deployment script resource ever runs.

module apim './provision-apimanagement.bicep' = {
  name: 'ApiManagement'
  params: { /* ... */ }
}
 
module fncapp './provision-functionapp.bicep' = {
  name: 'FunctionApp'
  dependsOn: [ apim ]
  params: { /* ... */ }
}
 
module uai './deploymentScript.bicep' = {
  name: 'UserAssignedIdentity'
  dependsOn: [ apim, fncapp ]
  params: { /* ... */ }
}

The deployment script module coming last is not a style choice, it is the whole point of the dependsOn chain, since the bash script inside it assumes both the Function App and APIM already exist and are ready to be wired together. Get this ordering wrong and the script fails trying to deploy code to a Function App resource that has not finished provisioning yet.

Compile this orchestration down the same way as the previous autopilot post, into an ARM template linked from a Deploy to Azure button, so the whole chain runs from a Portal wizard.

Azure Portal running the compiled template, provisioning infrastructure, deploying the function, and registering it in APIM in one pass.
Azure Portal running the compiled template, provisioning infrastructure, deploying the function, and registering it in APIM in one pass.

Once the deployment finishes, the function’s Swagger UI is reachable straight through the APIM gateway, confirming both the deployment and the API registration steps actually completed correctly rather than just the resource provisioning.

Function App Swagger UI, reachable through the APIM gateway after the full autopilot run completes.
Function App Swagger UI, reachable through the APIM gateway after the full autopilot run completes.

Comparing This to the GitHub Actions Version

The earlier GitHub Actions based version of this same idea needed a personal access token and a service principal with fairly broad rights stored in GitHub secrets, since the workflow itself had to authenticate to Azure from outside it. The Deployment Scripts version removes that entirely, the script authenticates using a user-assigned managed identity scoped to the resource group it is deployed into, which is a meaningfully tighter identity boundary than a subscription-wide service principal sitting in a GitHub secret.

The trade-off is portability. A GitHub Actions pipeline is easy to trigger from anywhere and easy to re-run on demand. A deployment script is bound to a single ARM deployment, it runs once as part of that deployment and is not meant to be re-triggered independently afterward. If your team wants an onboarding flow that gets used once per new environment, this pattern is a good fit. If you want an ongoing, frequently re-run deployment pipeline, a proper CI/CD pipeline is still the better tool, this is not a replacement for one.

It is also worth remembering that this whole flow is not idempotent by default. Running the same template twice against a resource group that already has these resources will hit conflicts on the resources that already exist. Treat this as a one-time bootstrap for a new environment rather than something safe to re-run casually against an existing one, unless you add explicit existence checks into the script yourself.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading