ARM Templates, Bicep, and the Azure Developer CLI
This section bridges the full course into deployable outputs. You will learn Azure Resource Manager and Bicep as the infrastructure foundation, the Azure Developer CLI (azd) as the packaging layer that combines infrastructure, code, and CI/CD into a single deployable unit, and then deploy and explore real security tools using these patterns. By the end of the lab you will have deployed a working security testing framework using a single azd up command.
azure.yaml, /infra, /srcazd up commandLearning journey: package once, deploy repeatedly through the five-stage pipeline.
Azure Resource Manager (ARM) is the management and deployment layer for Azure resources. Azure portal, Azure CLI, Az PowerShell, Bicep, and ARM templates all rely on it for control-plane operations against https://management.azure.com/.
Understanding ARM explains:
https://management.azure.com/ are needed for infrastructure automationMicrosoft.Automation, Microsoft.Logic, Microsoft.Web, Microsoft.KeyVaultEvery resource you create in the portal is a PUT request to an ARM REST API endpoint. The endpoint format is:
PUT https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProvider}/{resourceType}/{resourceName}?api-version={version}
When something fails during deployment, the ARM REST API returns an error code and message. Understanding the ARM layer helps you interpret error messages from az deployment group create and from the Azure portal's deployment history.
ARM is the shared control plane for Azure management and deployment tooling.
Resource providers and types: every Azure resource lives under a provider/type hierarchy.
ARM templates are JSON documents that declare the desired state of Azure resources. They are the original Infrastructure as Code format for Azure.
Four core authoring sections:
{
"parameters": {
"automationAccountName": {
"type": "string",
"metadata": { "description": "Name of the Automation Account" }
}
},
"variables": {
"location": "[resourceGroup().location]"
},
"resources": [
{
"type": "Microsoft.Automation/automationAccounts",
"apiVersion": "2023-11-01",
"name": "[parameters('automationAccountName')]",
"location": "[variables('location')]",
"identity": { "type": "SystemAssigned" },
"properties": { "sku": { "name": "Basic" } }
}
],
"outputs": {
"principalId": {
"type": "string",
"value": "[reference(parameters('automationAccountName'), '2023-11-01', 'full').identity.principalId]"
}
}
}
ARM JSON is verbose: a simple Automation Account with a managed identity and a Reader role assignment takes approximately 80 lines with nested resourceId() functions and dependsOn arrays. Understanding its structure is useful because Bicep compiles to it, and ARM deployment errors reference JSON structure.
ARM JSON declares desired state; Bicep compiles to the same engine.
ARM JSON is verbose by design: explicit dependencies, nested references, concat() everywhere.
Idempotent deployment: same template, same outcome - regardless of current state.
What-if preview: every change flagged before deployment runs.
Bicep is a Domain Specific Language that compiles to ARM JSON. It provides a much more readable and maintainable authoring experience.
Bicep authors cleaner; the same ARM engine deploys.
The same Automation Account in Bicep:
param automationAccountName string
param location string = resourceGroup().location
resource automationAccount 'Microsoft.Automation/automationAccounts@2023-11-01' = {
name: automationAccountName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
sku: {
name: 'Basic'
}
}
}
output principalId string = automationAccount.identity.principalId
Bicep advantages over ARM JSON:
.bicep filesresourceId() functionsconcat()Bicep modules allow you to compose large deployments from reusable files:
// main.bicep
module automationModule 'modules/automation.bicep' = {
name: 'automationDeploy'
params: {
accountName: 'my-automation-account'
location: location
}
}
A common Bicep pattern is to separate concerns into modules such as compute, storage, and RBAC. In azd-maester, the exact /infra layout varies by solution folder, so inspect the selected solution instead of assuming every template uses the same module breakdown.
Bicep module pattern: parent calls child modules with params, collects outputs.
Bicep VS Code IntelliSense: live schema, valid API versions, required properties surfaced inline.
Running the same Bicep template twice produces the same result without errors. If the resource already exists, ARM updates it to match the desired state. If it does not exist, ARM creates it. This makes Bicep templates safe to run repeatedly and ideal for CI/CD pipelines.
Before running a deployment, preview what it will create, modify, or delete:
az deployment group what-if \
--resource-group myRG \
--template-file main.bicep \
--parameters automationAccountName=my-aa
Output shows:
- Green: new resources to create
- Yellow: existing resources to modify
- Red: resources to delete
Run what-if before every first deployment of a new template.
The Azure Developer CLI bundles infrastructure-as-code (Bicep), application code, and CI/CD pipeline configuration into a single deployable, shareable template.
azd up: one command replaces three error-prone manual phases.
Three-part azd template structure: azure.yaml manifest, /infra for Bicep, /src for code.
Install:
winget install microsoft.azd # Windows
brew tap azure/azd && brew install azd # macOS
| Command | Purpose |
|---|---|
azd init -t <template> |
Clone a template from GitHub |
azd auth login |
Authenticate with Azure |
azd provision |
Deploy infrastructure only (Bicep) |
azd deploy |
Deploy application code only |
azd up |
Provision infrastructure and deploy code in one step |
azd down |
Tear down all provisioned resources |
azd pipeline config |
Configure GitHub Actions or Azure DevOps WIF pipeline |
azd env list |
List environments (dev, staging, prod) |
In azd-maester, the repository root is a template chooser. Each solution folder has its own deployable azure.yaml. The recommended automation-account solution looks like this:
name: maester-automation-account
metadata:
template: maester-automation-account@0.0.1
infra:
provider: bicep
path: infra
workflows:
up:
- azd: provision
hooks:
preup:
shell: pwsh
run: ./scripts/Run-AzdPreUp.ps1
interactive: true
preprovision:
shell: pwsh
run: ./scripts/Run-AzdPreProvision.ps1
interactive: true
postprovision:
shell: pwsh
run: ./scripts/Run-AzdPostProvision.ps1
interactive: true
predown:
shell: pwsh
run: ./scripts/Run-AzdPreDown.ps1
interactive: true
The repository-root azure.yaml is intentionally not deployable: it runs a guard script that tells you to cd into automation-account, container-app-job, function-app, or azure-devops before running azd up.
azure.yaml: name, infra, and hooks describe the deployable solution.
azd deployment phases: init, auth, provision, deploy, plus pipeline config and teardown shortcuts.
Core azd command set: azd up combines provision and deploy, the rest support the lifecycle.
Running azd pipeline config automatically:
AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID)..github/workflows/.This is the same WIF pattern from Section 4, automated for the azd deployment context. The generated subject claim is scoped to the specific repository.
azd pipeline config automates WIF setup: federated credential replaces client secrets entirely.
Three implementations ensure break-glass accounts remain excluded from all Conditional Access policies:
emergency-access-exclusion.json): runs on a schedule to check and remediate CA policy exclusions. Best for: regular remediation with minimal operational complexity.emergency-access-exclusion-sentinel.json): triggers only when a CA policy change event fires in Sentinel. More efficient and near-real-time.emergency-access-exclusion.ps1): runs in an Automation Account, schedulable or webhook-triggered.All implementations use managed identity with scoped Graph API permissions to read and modify CA policies.
Source: https://github.com/nathanmcnulty/nathanmcnulty/tree/main/Entra/emergency-access
Automation patterns for Entra Entitlement Management:
Demonstrates: Graph Identity Governance API, access package creation and assignment, attribute-driven access control without manual IT intervention.
Source: https://github.com/PatriotConsultingTech/Community/tree/main/Webinars/2025/AccessManagement
Intune Remediations (detection + remediation PowerShell script pairs) function as a lightweight endpoint telemetry pipeline. A detection script collects Defender for Endpoint performance metrics from each managed endpoint and uploads to Log Analytics or Azure Blob Storage via REST API - no additional agent required.
Two upload patterns:
- Log Analytics Data Collector API with workspace shared key
- Azure Monitor Logs Ingestion API (DCR-based) for schema-validated ingestion
This demonstrates that endpoints are API clients like any cloud automation workload - the token and REST API patterns from this course apply at the endpoint layer.
Source: https://github.com/nathanmcnulty/nathanmcnulty/tree/main/DefenderForEndpoint/Performance
This advanced pattern enables delegated-user-style automation with phishing-resistant credentials for APIs that require user context rather than app-only access.
Two components:
Key Vault-backed FIDO2 passkeys: instead of a hardware security key, the ECDSA private key material lives in Azure Key Vault. An Automation Account with managed identity access retrieves the key material and performs FIDO2/WebAuthn authentication headlessly.
- Initialize-PasskeyKeyVault.ps1: configures the vault with the required EC key type
- Register-KeyVaultPasskeyViaTAP.ps1: registers the passkey using a Temporary Access Pass
- PasskeyLogin.ps1: performs headless authentication using the vault-stored key
XDRInternals PowerShell module: wraps Defender XDR portal internal APIs for automated advanced hunting, incident management, and security operations tasks not yet exposed through the official Graph Security API.
Key Vault holding the passkey private key carries credential-store obligations.
The Key Vault containing the passkey private key is functionally equivalent to the user's credentials. Anyone with access to that Key Vault can authenticate as that user. Apply strict RBAC, audit logging, and Privileged Identity Management to both the Key Vault and the automation account that accesses it.
Source: https://github.com/nathanmcnulty/nathanmcnulty/tree/main/Entra/passkeys/keyvault
Source: https://github.com/MSCloudInternals/XDRInternals
Maester is a PowerShell security testing framework built on Pester with 286+ automated configuration tests for Microsoft 365 and Entra, mapped to MITRE ATT&CK. The azd-maester project packages Maester into four ready-to-deploy azd templates:
All templates use managed identities - no secrets. After you cd into one of the four solution folders, one azd up command deploys that solution's infrastructure, permissions, and scheduling.
azd-maester: four deployment options, one command, same managed-identity foundation.
What azd-maester deploys: Automation Account, MI, Graph permissions, schedule - one command.
Source: https://github.com/nathanmcnulty/azd-maester
Automation that sends real-time notifications to users and security teams when:
Demonstrates: Entra audit log monitoring via Log Analytics or Event Hubs, Logic App or Function App triggered on audit events, Graph API enrichment with device and user details, Teams adaptive card delivery.
Automation to ensure UAL settings are correct across the tenant:
Demonstrates: Security and Compliance PowerShell, Exchange Online PowerShell, and scheduled Automation Account runbooks for compliance state validation.
Have Azure Developer CLI, Git, Azure CLI, a subscription/resource group, and the prepared azd-maester checkpoint available. If live deployment is slow, inspect the provided output and screenshots, then focus on Bicep structure, managed identity permissions, pipeline federation, and cleanup with azd down.
Deploy the automation-account solution from the azd-maester repository with azd up, inspect the Bicep files that created the resources, configure GitHub Actions federation with azd pipeline config, and then cleanly remove the environment with azd down.
az --version)azd version)GitHub CLI and Azure Developer CLI:
Fork the repo, move into the deployable solution, and verify auth
gh repo fork <upstream-owner>/azd-maester --clone Set-Location .\azd-maester\automation-account # Confirm azd is already authenticated before starting the environment work. azd auth login --check-status
The repository root is intentionally not deployable. If you run azd up from the root, the root guard stops the deployment and instructs you to choose a solution folder. Stay inside automation-account for the rest of the lab.
Azure Developer CLI:
Create the environment and pin the validated region
$environmentName = "lab-maester-aa" azd env new $environmentName azd env set AZURE_LOCATION centralus # Review the local azd state for this solution. Get-ChildItem .azure -Force Get-Content ".azure\$environmentName\.env"
Expected outcomes:
- The environment is created under automation-account\.azure\<env-name>
- The region is pinned to centralus, which avoided Automation Account quota issues during validation
- The local .env file shows the azd environment metadata
Azure Developer CLI:
Deploy the solution from the solution folder
azd up # Review the deployment outputs after provision and deploy complete. azd env get-values
Expected outcomes:
- azd up provisions the resource group and the Automation Account stack from the Bicep files in this solution
- The deployment uploads the application content after infrastructure is ready
- azd env get-values shows the environment-scoped resource names and identifiers
Native PowerShell:
Inspect the Bicep files that drove the deployment
Get-ChildItem .\infra -Recurse -Filter *.bicep | Select-Object FullName Get-Content .\infra\main.bicep
Identity and Azure role assignments.Expected outcomes:
- The resource group contents line up with the Bicep file structure
- The Automation Account uses managed identity rather than a stored secret or certificate
- The Bicep files explain the Azure resources you just deployed
Native PowerShell and Azure Developer CLI:
Prepare the solution for pipeline generation
# Stay in .\azd-maester\automation-account # azd pipeline config expects workflow folders to exist in this repo shape. New-Item -ItemType Directory -Path ..\.github\workflows -Force | Out-Null New-Item -ItemType Directory -Path .\.github\workflows -Force | Out-Null $resourceGroupName = (azd env get-values | Select-String '^AZURE_RESOURCE_GROUP=').Line.Split('=')[1].Trim('"') $resourceGroupId = az group show --name $resourceGroupName --query id -o tsv # Populate the scope explicitly. The generated workflow depends on this value. azd env set AZURE_RBAC_SCOPES $resourceGroupId azd pipeline config
GitHub Actions:
Patch the generated workflow for this multi-solution repository
on: workflow_dispatch: push: branches: - main permissions: id-token: write contents: read jobs: build: runs-on: ubuntu-latest env: AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} AZURE_LOCATION: centralus AZURE_RBAC_SCOPES: ${{ vars.AZURE_RBAC_SCOPES }} AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} steps: - uses: actions/checkout@v4 - uses: Azure/setup-azd@v2 - uses: azure/login@v2 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - name: Log in with azd working-directory: automation-account shell: pwsh run: | azd auth login ` --client-id "$Env:AZURE_CLIENT_ID" ` --federated-credential-provider "github" ` --tenant-id "$Env:AZURE_TENANT_ID" - name: Provision Infrastructure working-directory: automation-account run: azd provision --no-prompt - name: Deploy Application working-directory: automation-account run: azd deploy --no-prompt
Az CLI:
Grant the pipeline app the Graph permissions this solution's hooks expect
$pipelineClientId = "<AZURE_CLIENT_ID from GitHub variables>" $pipelineServicePrincipalId = az ad sp list --filter "appId eq '$pipelineClientId'" --query "[0].id" -o tsv $graphServicePrincipal = az rest ` --method get ` --uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles" | ConvertFrom-Json $graphServicePrincipalId = $graphServicePrincipal.value[0].id $existingAssignments = az rest ` --method get ` --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$pipelineServicePrincipalId/appRoleAssignments" | ConvertFrom-Json foreach ($roleValue in @("Directory.Read.All", "Application.Read.All", "AppRoleAssignment.ReadWrite.All")) { $roleId = ($graphServicePrincipal.value[0].appRoles | Where-Object { $_.value -eq $roleValue -and $_.allowedMemberTypes -contains "Application" } | Select-Object -First 1 -ExpandProperty id) if (-not ($existingAssignments.value | Where-Object { $_.resourceId -eq $graphServicePrincipalId -and $_.appRoleId -eq $roleId })) { $bodyPath = Join-Path $env:TEMP ("section6-{0}.json" -f ($roleValue -replace '[^A-Za-z0-9]', '-')) (@{ principalId = $pipelineServicePrincipalId resourceId = $graphServicePrincipalId appRoleId = $roleId } | ConvertTo-Json -Compress) | Set-Content -Path $bodyPath -Encoding ascii az rest ` --method post ` --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$pipelineServicePrincipalId/appRoleAssignments" ` --headers "Content-Type=application/json" ` --body "@$bodyPath" | Out-Null } }
Settings > Secrets and variables > Actions and confirm the Azure variables are present.azd pipeline config and confirm the federated credential is scoped to your repository.Expected outcomes:
- azd pipeline config creates the Entra app registration and GitHub repository variables
- The workflow uses OIDC rather than a client secret
- In this multi-solution repo, the workflow must run from automation-account, include both Azure CLI login and azd auth login, carry the Graph application permissions the pre-provision and post-provision hooks require, and pin the region that actually has Automation quota available
Azure Developer CLI:
Remove the environment and purge the Azure resources
azd down --environment lab-maester-aa --force --purge --no-prompt
Expected outcomes:
- The resource group is deleted cleanly
- The environment is removed without interactive prompts
- The teardown removes resource locks before deleting the Automation stack
automation-account or the root guard will stop you.centralus if your first choice fails.azd pipeline config can fail if AZURE_RBAC_SCOPES is empty. Set it explicitly to the deployed resource group ID before generating the pipeline.azure/login and azd auth login. The solution pre-provision hook checks Azure CLI authentication with az account show.Directory.Read.All, Application.Read.All, and AppRoleAssignment.ReadWrite.All because the solution hooks validate Graph access and grant Graph app roles during setup.AZURE_LOCATION to the same region you validated locally. Otherwise CI may fall back to a quota-constrained region such as eastus2.automation-account. Running the workflow from the repo root hits the root guard again.Course key takeaways: the five principles to apply directly in production.
azd up do in a single command?azd pipeline config configure automatically?azd up is one command for a complete solution: infrastructure, code, and configuration.what-if before every first deployment of a new template.azd down cleanly removes everything - use it to avoid orphaned resources from labs and pilots.Volatile platform behavior and dated claims in this section were checked against these current sources on April 26, 2026:
All links below were reviewed on 2026-03-10. Microsoft Learn and Microsoft documentation are the primary sources. Community and project references are included where they add operational context or point to actively maintained tooling.
Section 6 - Packaging Tools and azd - Reference Guide