GitHub Copilot for Microsoft Azure

GitHub Copilot for DevOps & InfrastructureAcademy lesson 48Cluster 4 · Lesson 10 of 13Intermediate → Advanced15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for Microsoft AzureGitHub Copilot for DevOps & Infrastructure10Intermediate → Advanced/github-copilot/devops/azure/

This is the one lesson in Cluster 4 where the validation coverage is genuinely poor, and saying so is the honest way to open it.

Neither the Bicep CLI nor the Azure CLI is installed in the environment these examples were written in. The Bicep template below was reviewed against Microsoft’s documentation and was not compiled. It is labelled an example implementation rather than a tested one, and the one factual claim in it that could be independently verified — a built-in role GUID — was checked against Microsoft Learn and is quoted below.

That is a worse result than the Terraform, Kubernetes and Docker lessons, and it is stated rather than hidden. Everything else in this lesson is technique that does not depend on having run a command.

Subscription context

Azure’s version of the ambient-context problem is the sharpest in this cluster, because the blast radius of the wrong context is a whole subscription rather than a namespace or a state file.

az account show --query "{name:name, id:id}" -o table

That is the equivalent of kubectl config current-context and aws sts get-caller-identity, and it belongs before anything that writes.

Three things make Azure’s version worse than the others.

The context persists across sessions. az account set writes to a profile file, so the subscription you selected on Tuesday is still selected on Friday, in a different terminal, after a reboot.

Resource groups compound it. A command needs both a subscription and a resource group, and az configure --defaults group=… makes the second ambient too. A generated command with neither flag depends on two invisible pieces of state.

The names are frequently similar. rg-app-prod and rg-app-preprod differ by three characters, and tab completion does not care which one you meant.

Bicep, and what to check in it

Bicep is a domain-specific language that transpiles to ARM JSON. It is considerably more pleasant than ARM templates and Copilot writes it well — the syntax is regular and the resource schema is published.

Four things to check in generated Bicep.

API versions. Every resource declaration carries one: Microsoft.Storage/storageAccounts@2023-05-01. A generated template will use whatever version was common in its training data, and the available properties differ between versions — so a property you expect may simply not exist, and the error message is about an unrecognised property rather than about the version.

Parameter decorators. @description, @allowed, @minLength, @maxLength, @secure. These are Bicep’s equivalent of Terraform’s validation blocks and they are the cheapest gate available. @secure on any parameter holding a secret is the one that matters most: it keeps the value out of the deployment history.

Deterministic naming. uniqueString(resourceGroup().id) produces the same value for the same resource group every time, so redeploying does not create a second resource and orphan the first. A generated template using utcNow() or a random suffix produces a new name on every deployment, which is a specific and expensive mistake.

Outputs. Everything a Bicep deployment outputs is stored in the deployment history and readable by anyone with reader access to the resource group. A generated template that outputs a storage account key or a connection string has put a credential somewhere durable.

Practical project: a storage account with its defaults stated

Practical example

A Bicep template with the properties that are wrong by default

Show what a reviewed Azure resource declaration looks like, and be explicit about what could not be validated.

Status
Example implementation
Not executed here
This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
Requires
The Bicep CLI and the Azure CLI, plus a subscription. Neither tool is installed in the environment this was written in.

What was and was not checked

CheckExecuted?Result
jq empty bicep/parameters.example.jsonyesvalid JSON
bicep build main.bicepnoBicep CLI not installed
az deployment group what-ifnoAzure CLI not installed, no subscription
Built-in role GUIDyes2a2b9908-6ea1-4ae2-8e65-a410df84e7d1 confirmed as Storage Blob Data Reader against Microsoft Learn

Files

examples/cluster-4/azure

copilot-azure-demo/ ├── bicep/ │ ├── main.bicep │ └── parameters.example.json ├── README.md └── .github/ └── instructions/ └── azure.instructions.md

The properties that are wrong by default

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: take(storageName, 24)
  location: location
  sku: {
    name: environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    // Every one of these is a default that is wrong for production if left
    // unset. `supportsHttpsTrafficOnly` in particular defaults to true on new
    // accounts but is worth stating, because a template that omits it inherits
    // whatever the API version's default happens to be.
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    allowSharedKeyAccess: false
    publicNetworkAccess: 'Disabled'
    defaultToOAuthAuthentication: true
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'AzureServices'
    }
    encryption: {
      services: { blob: { enabled: true } }
      keySource: 'Microsoft.Storage'
      requireInfrastructureEncryption: true
    }
  }
}

allowSharedKeyAccess: false is the one worth understanding. Storage account keys are shared secrets that grant full data-plane access, do not expire, and cannot be scoped. Disabling them forces every access through Entra ID, which means access is a role assignment you can audit and revoke. Generated templates never set it, because every quickstart uses a connection string.

Role assignment by GUID

// Role assignment by built-in role ID rather than by name. Names are not
// stable identifiers; the GUID is.
var storageBlobDataReader = subscriptionResourceId(
  'Microsoft.Authorization/roleDefinitions',
  '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
)

resource readerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: storage
  // A deterministic GUID, so redeploying is idempotent rather than a conflict.
  name: guid(storage.id, readerPrincipalId, storageBlobDataReader)
  properties: {
    roleDefinitionId: storageBlobDataReader
    principalId: readerPrincipalId
    principalType: 'ServicePrincipal'
  }
}

Two details that generated role assignments get wrong.

The assignment name must be a GUID, and it must be deterministic. Using newGuid() produces a different name every deployment, which means a second assignment rather than an update — and eventually a resource group full of duplicate role assignments.

principalType should be set. Without it, Azure occasionally fails the assignment with a replication error when the principal was created moments earlier in the same deployment.

Scoping

The scope: storage line is the important one. A role assignment’s scope decides what it applies to, and the options are a resource, a resource group, a subscription, or a management group. Generated assignments default to the resource group scope, which grants the role on everything in the group rather than on the one resource you meant.

Validation

what-if is the plan

az deployment group what-if is Azure’s equivalent of terraform plan, and it is under-used. It returns a change list categorised as Create, Delete, Modify, Deploy, NoChange and Ignore, and the Delete section is the one to read first.

az deployment group what-if \
  --subscription "$SUBSCRIPTION" \
  --resource-group "$RESOURCE_GROUP" \
  --template-file main.bicep \
  --parameters @parameters.json

Two caveats worth knowing, because they affect how much you can trust it. what-if is not perfectly accurate for every resource provider — some report Modify where nothing changes, which trains people to skim. And it does not predict the effect of --mode Complete, which is where the deletions come from.

bicep build is the offline check: it transpiles to ARM JSON and fails on a syntax or schema error, needing no subscription. bicep lint adds a rule set on top. Both belong in CI, and both are what this lesson could not run.

Identity: the thing to get right

Azure’s identity story is genuinely good and generated code consistently uses the weaker option.

Managed identity attaches an identity to a resource — a VM, an App Service, a container app — with no credential anywhere. The resource requests a token from a local endpoint; there is nothing to rotate and nothing to leak. This should be the default for any Azure resource calling another Azure resource.

A service principal with a client secret is the alternative, and it is what suggestions produce, because it is what most tutorials show. It is a password: it lives somewhere, it expires, and someone has to rotate it.

Workload identity federation is the equivalent of AWS’s OIDC pattern and is what CI should use. GitHub Actions exchanges its token for an Azure one, with no stored secret. As with AWS, the federated credential’s subject is the control — a federated credential scoped to a repository and branch is safe; one scoped to just an organisation is not.

Copilot promptAsking for the right identityCopilot Chat

This App Service needs to read from a Key Vault and write to a storage account.

Use a system-assigned managed identity, not a service principal with a secret.

Show the role assignments needed, scoped to the specific vault and storage account rather than to the resource group, and use built-in role GUIDs.

Tell me what breaks if I use a user-assigned identity instead.

RBAC

Azure RBAC is additive and its scopes are hierarchical — management group, subscription, resource group, resource — with an assignment at any level applying to everything below it. Two consequences for generated code.

Scope creep is invisible. An assignment written at subscription scope reads almost identically to one at resource scope. The difference is one property and several thousand resources.

Owner and Contributor are not the same, and neither is what you want. Contributor can do anything except manage access; Owner can also grant roles, which means Owner can grant itself anything. A generated assignment of Contributor “so it can deploy” is usually over-broad — there is almost always a service-specific role that fits.

For anything narrower than the built-ins allow, custom role definitions exist and Copilot writes them reasonably. The thing to check is notActions, which subtracts from actions and is easy to reason about backwards.

The services, and their defaults

Five services account for most generated Azure infrastructure, and each has a default worth knowing about.

App Service. The properties suggestions omit are httpsOnly: true, minTlsVersion, ftpsState: 'Disabled' — FTP deployment is enabled by default and is a credential-based path into your application — and alwaysOn, without which a service on a paid plan is unloaded when idle and the first request after that takes seconds. Application settings are the configuration mechanism, and a generated template that puts a connection string directly in one has committed a secret; a Key Vault reference is the correct form.

Azure Functions. Everything above plus the consumption-plan cold-start consideration, and the same Key Vault reference point for connection strings. A Function App requires a storage account for its own state, and generated templates frequently share it with application data — which is worth separating, because the runtime’s storage account holds function keys.

AKS. The cluster-level decisions generated templates skip: Entra ID integration with Kubernetes RBAC rather than local accounts, a private API server endpoint, disableLocalAccounts, and a managed identity rather than a service principal for the cluster itself. Everything in the Kubernetes lesson then applies to the workloads on top.

Virtual machines. Check the image reference — a hard-coded version string goes stale, latest moves — and whether disk encryption is on. The bigger point is that a generated VM usually comes with a public IP and an NSG rule permitting SSH or RDP from anywhere, which is the Azure equivalent of the security group problem in the AWS lesson. Azure Bastion or just-in-time access is the alternative and no suggestion proposes it.

Networking. NSG rules are evaluated by priority, lowest number first, and a generated rule at priority 100 permitting Internet will shadow the deny at 200 that someone added carefully. Read the priorities, not just the rules. Service endpoints and private endpoints are different things — the first keeps traffic on the Azure backbone, the second gives the resource an address in your network and is what publicNetworkAccess: 'Disabled' implies you want.

Terraform or Bicep

Both are viable on Azure and the choice changes what review looks like.

Bicep is first-party, tracks new resource properties immediately, and has no state file — Azure Resource Manager is the state. That last point is a genuine simplification: no backend to configure, no state to leak, no drift between a file and reality. Its weakness is that it is Azure-only, and what-if is less precise than a Terraform plan.

Terraform gives you one language across clouds, a much better plan, and a mature policy-scanning ecosystem — Checkov’s Azure coverage is good, and running it was possible for the AWS and Google Cloud examples in this cluster precisely because they are Terraform. The cost is state management and a provider that sometimes lags new Azure features.

For AI-assisted work specifically, Terraform has one practical advantage worth naming: the offline validation is better. terraform validate type-checks against the provider schema, and checkov has policies for Azure resources. The Bicep equivalent, bicep build, checks the schema but the policy-scanning story is thinner. That is the reason this lesson has the weakest validation coverage in the cluster, and it is a reasonable input to the decision.

Whichever you pick, say so in your repository instructions. A codebase getting Bicep suggestions in a Terraform repository is one where nobody wrote it down.

Monitoring and diagnostics

Azure resources do not send logs anywhere by default, and this is the single most commonly omitted thing in generated Azure infrastructure.

A diagnostic setting is a separate resource that routes a service’s logs and metrics to a Log Analytics workspace, a storage account, or an event hub. Without one, the resource emits nothing you can query and there is no way to backfill — the data for last Tuesday does not exist.

resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
  scope: storage
  name: 'to-log-analytics'
  properties: {
    workspaceId: logAnalyticsWorkspaceId
    logs: [ { categoryGroup: 'audit', enabled: true } ]
    metrics: [ { category: 'AllMetrics', enabled: true } ]
  }
}

Ask for it by name, for every resource that matters. It is four lines and it is the difference between having an incident timeline and not.

Two related points. Application Insights is the application-level equivalent and connects via a connection string that belongs in Key Vault like any other. And Azure Policy is the guardrail layer — the equivalent of AWS Service Control Policies — which can require diagnostic settings, deny public network access, or enforce tagging at subscription scope. As with AWS, it is the control that works when the review does not, and no generated template includes it because it is not part of the resource you asked for.

Azure-specific risks

Wrong subscription or resource group. The headline. Verify before every write.

--mode Complete. Covered below. The single most dangerous flag in the Azure CLI.

Role assignment scope too broad. Resource group where the resource was meant.

Client secrets in files or pipeline variables. Use managed identity or workload identity federation.

Storage account defaults inherited rather than stated. Public blob access, shared key access, TLS version, network rules.

Public endpoints. publicNetworkAccess: 'Enabled' on data services, and network security group rules from Internet.

Outputs containing secrets. Deployment history is durable and readable.

Soft-delete and purge protection missing on Key Vault. Without them a deleted vault and its secrets are gone; with them, recoverable. Generated vaults omit both.

Non-deterministic names. newGuid() or utcNow() in a name, producing a new resource on every deployment.

Naming, tagging and the things that are hard to change later

Two Azure constraints trip up generated templates in ways that only become visible at deployment time.

Naming rules differ per resource type and are unusually strict. A storage account name is 3–24 characters, lowercase alphanumeric only, and globally unique across all of Azure — no hyphens, no underscores, and a name someone else took is a name you cannot have. A Key Vault is 3–24 characters but permits hyphens. A resource group permits 90 characters including periods. Generated templates apply one convention everywhere, and the failure is a deployment error several minutes in.

The pattern that handles it is the one in the example: build the name, then take(name, 24) to truncate, with uniqueString(resourceGroup().id) supplying the uniqueness deterministically. Ask for the length constraint explicitly for any resource type you are not certain about.

Tags are how cost is attributed and they do not inherit. A tag on a resource group is not applied to the resources inside it, which surprises people consistently. Generated templates tag inconsistently or not at all, and untagged resources are invisible in cost analysis — which means the first time anyone notices them is on a bill.

The fix is a tags parameter threaded through every resource, or Azure Policy appending tags at subscription scope. Worth putting in the instructions file: “every resource takes a tags parameter and applies it”, because retrofitting tags across an existing estate is tedious in a way nobody enjoys.

One further point that belongs here: resource moves are limited. Not every resource type supports moving between resource groups or subscriptions, and some that do have conditions. A generated template that puts a resource in the wrong group is not always a five-minute fix, which is an argument for reading the what-if output carefully the first time rather than deploying and rearranging.

Destructive commands

--mode Complete deserves particular emphasis because the flag reads like thoroughness rather than deletion, and because generated deployment scripts include it when asked to make deployments “clean” or “consistent with the template”.

Review workflow

Accepting generated Azure artifacts
  1. Check the subscription and resource groupHuman judgementaz account show. Is the target in the command, or in ambient state?
  2. Run bicep buildTranspiles and schema-checks offline, with no subscription required.
  3. Check the API versionsHuman judgementDo the properties in the template exist in the version declared?
  4. Check every role assignmentHuman judgementScope, role GUID, deterministic name, and principalType.
  5. Grep for secretsHuman judgementParameter defaults, outputs, connection strings, storage keys.
  6. Run az deployment group what-ifAnd read the Delete section first.
  7. Confirm the deployment modeHuman judgementIncremental unless someone has explicitly decided otherwise, in writing.

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Best practices

  • Put --subscription and --resource-group in every command rather than relying on defaults.
  • Use @secure() on secret parameters and never output one.
  • Managed identity first, workload identity federation for CI, service principal secrets last.
  • Role assignments by built-in GUID, scoped to the resource, with a deterministic guid() name.
  • State the storage and network properties explicitly rather than inheriting API defaults.
  • bicep build and bicep lint in CI; what-if before every deployment.
  • Never --mode Complete without reading what is in the resource group first.

Common mistakes

  • Running a generated command against whichever subscription was last selected.
  • Accepting a service principal with a secret where managed identity would work.
  • Assigning Contributor at resource group scope because it makes the deployment succeed.
  • Outputting a connection string and forgetting deployment history is durable.
  • Using --mode Complete to “clean up” a resource group.

Where to go next

GitHub Copilot for AWS is the equivalent lesson for the other large cloud, where the IAM trust-policy problem takes a similar shape. GitHub Copilot for C# covers the language most Azure application code is written in, and GitHub Copilot for Terraform is the alternative to Bicep for teams that provision more than one cloud.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources