Azure Infrastructure as Code

Azure Verified Modules for Bicep: Consuming, Composing & Contributing Resource Modules

You opened a fresh main.bicep to stand up a storage account and a Key Vault, and forty lines later you were still typing: minimumTlsVersion, allowBlobPublicAccess, a diagnostic setting, an RBAC role assignment, a private-endpoint block, a @secure() parameter for the SAS, a network ACL with the right default action. Every one of those lines is a place to get it subtly wrong — and you have written the same lines, slightly differently, in five other repos. That is the exact waste Azure Verified Modules (AVM) exists to kill. AVM is Microsoft’s single, official library of pre-built, security-hardened Bicep (and Terraform) modules that you consume from a public registry by version instead of hand-authoring resource blocks — so the storage account you deploy already has TLS 1.2, public access off, diagnostics wired and managed-identity RBAC baked in, reviewed by the product group, not copy-pasted from a blog.

This is the practical guide to using AVM the way a platform team actually does: you will learn the difference between a resource module (one well-architected resource, like avm/res/storage/storage-account) and a pattern module (a multi-resource solution, like a hub-and-spoke network), reference one by its exact br/public: path and pinned version, wire its interface of standardised parameters and outputs, and compose several resource modules into your own pattern you ship to dev/test/prod. We treat AVM not as magic but as ordinary Bicep modules that live in a registry and follow a strict specification — because that is exactly what they are, and understanding the spec is what lets you trust them, override them, and eventually contribute back.

By the end you will stop writing storage and Key Vault boilerplate by hand. When a new project starts you will reach for the registry, pin the version, fill the handful of parameters that genuinely vary, and get a deployment that already passes most of your security review — then know how to extend it, where the version-pinning landmines are, and how to read an AVM module’s source when its interface surprises you. This builds directly on plain Bicep Modules, Loops and Conditions; AVM is what those constructs look like at industrial scale, owned by Microsoft.

What problem this solves

Hand-authored infrastructure rots in three predictable ways, and AVM targets all three. First, drift: the storage module in repo-a set allowBlobPublicAccess: false and the one in repo-b, copied six months later, forgot it — now you have an inconsistent estate and no single place to fix it. Second, incompleteness: a “good enough” hand-rolled module ships the resource but skips the diagnostic setting, the lock, the RBAC assignment and the private endpoint, so every team re-discovers those gaps in production. Third, maintenance burden: when a new API version or security default lands, you must find every copy and update it. AVM moves all three problems to Microsoft: the modules are owned by the product groups, aligned to the Well-Architected Framework (WAF), version-published, and updated centrally — you consume a version and upgrade on your schedule.

What breaks without it is subtle because nothing fails on day one. A hand-rolled estate works fine until an auditor asks “show me that every storage account enforces TLS 1.2 and disables public blob access” and the honest answer is “most of them, probably — let me check 40 templates.” Or a junior engineer copies a five-month-old block that predates a hardening and silently re-introduces a hole. The cost is not a crash; it is the slow accumulation of inconsistency that turns “deploy a storage account” from a one-line decision into a 40-line review every time.

Who hits this: any team past one repository or environment, hardest on platform / landing-zone teams guaranteeing a secure baseline across dozens of application teams, on regulated workloads that must evidence WAF alignment, and on anyone tired of re-litigating the same minimumTlsVersion argument in review. AVM does not remove your responsibility to configure correctly — it gives you a vetted, versioned starting point so the default is already safe, and the only things you type are the values that genuinely differ between deployments. What AVM replaces, and what you still own:

Concern Hand-authored Bicep With AVM What you still own
Secure defaults You type TLS, public-access, ACLs each time Baked into the module, WAF-aligned Choosing to override a default deliberately
API version You pick and update @2023-05-01 everywhere Module pins a tested API version Upgrading the module version on your schedule
Diagnostics / RBAC / locks Often skipped; re-discovered in prod First-class interface parameters Supplying the workspace ID, principal IDs, lock level
Consistency across repos Drifts as copies diverge One registry path, one version Pinning the same version across repos
Maintenance You find-and-update every copy Microsoft updates the module centrally Reading the changelog before you bump

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with plain Bicep: writing a resource, declaring param/var/output, invoking a local module, and deploying with az deployment group create. If any of that is shaky, do Deploy Your First Bicep File From Scratch and Bicep Modules, Loops and Conditions first — AVM modules are Bicep modules, so everything about parameters, outputs and nested deployments applies unchanged. You need az 2.40.0+ (which bundles a recent Bicep CLI) and Contributor on a subscription or resource group.

This sits in the Infrastructure as Code track, one rung above plain modules and below landing-zone automation. The mental model is a ladder: a single resource → your own local module → a registry-published module → AVM, Microsoft’s officially-owned registry modules. AVM does not replace your Bicep skills; it is where you point them. It pairs with secret management — Azure Key Vault: Secrets, Keys and Certificates — and with governance, because Azure Policy is the platform backstop that enforces what AVM defaults encourage. If your team uses Terraform, AVM ships a parallel library under the same registry-and-version discipline, contrasted with the Terraform on Azure remote-state workflow.

Core concepts

Six mental models make every later step obvious.

AVM is just Bicep modules in a registry — with rules. An AVM module is an ordinary Bicep file (typed parameters and outputs) published to a registry but bound by the AVM specification — mandatory requirements covering naming, the interface, telemetry, testing and WAF alignment. You consume it exactly like any registry module — module x 'br/public:avm/res/...:0.x.y' = { … } — and everything you know about Bicep modules (nested deployments, the 800-resource limit, outputs) still applies. The registry and the spec are the only new ideas.

Two flavours: resource modules and pattern modules. A resource module (avm/res/<service>/<resource>) deploys exactly one logical, WAF-aligned resource; a pattern module (avm/ptn/<area>/<solution>) deploys a multi-resource solution by composing resource modules. A small utility (avm/utl) category holds helpers. The next section drills into this choice — it is the first decision with AVM.

The interface is standardised — that is the whole point. Because every resource module follows the same spec, they share a common interface: the same optional parameters appear with the same names and shape across every module. tags, lock, roleAssignments, diagnosticSettings, managedIdentities, privateEndpoints mean the same thing whether you deploy storage, Key Vault or a VNet. Learn the interface once and you can drive any AVM module. Module-specific parameters (a storage account’s skuName, a vault’s enablePurgeProtection) sit alongside these common ones.

You pin a version, always. AVM modules are published with semantic versions (MAJOR.MINOR.PATCH). You reference an exact version after the colon — …/storage-account:0.9.1 — and you never float. Bicep does not support a “latest” tag for registry modules the way a container image does; you must name a version, and that is a feature. A MAJOR bump can change the interface; a MINOR adds capability; a PATCH fixes a bug. Pinning means a git pull never silently changes what your infrastructure deploys.

Modules during the 0.x line are still evolving. Most AVM modules are pre-1.0.0. Under semantic versioning that means the interface can change between minor versions, not only majors — so even a 0.8.0 → 0.9.0 bump can be breaking. This is the single most important consumption fact: while a module is 0.x, treat every minor bump as potentially breaking, read the release notes, and run what-if before applying. From 1.0.0, normal semver applies and only MAJOR bumps break the interface.

br/public: is an alias for Microsoft’s registry. It is a built-in Bicep alias resolving to the public Microsoft Container Registry (MCR) where AVM modules are hosted (mcr.microsoft.com/bicep/...). On first build Bicep restores (downloads and caches) the referenced module locally, then transpiles against it. No login is needed — the public modules are anonymously pullable; your own modules go to your ACR under a br/ alias you configure in bicepconfig.json.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary repeats these for lookup; this table is the mental model side by side:

Concept One-line definition Where it lives Why it matters
AVM Microsoft’s official library of WAF-aligned IaC modules Public registry + spec The vetted starting point you consume
Resource module One WAF-aligned Azure resource avm/res/<svc>/<res> The unit you reference most
Pattern module A multi-resource solution composed of resource modules avm/ptn/<area>/<sol> A whole architecture in one reference
Registry (MCR) Where public AVM modules are hosted mcr.microsoft.com/bicep The source br/public: points at
br/public: Bicep alias for the public registry bicepconfig.json (built-in) The prefix in every AVM reference
Pinned version The exact MAJOR.MINOR.PATCH after the colon Your module line Stops silent infra changes on git pull
Interface The standardised common + module parameters The module spec Learn once, drive any module
Restore Bicep downloading + caching a registry module Local module cache The first-build step that needs network
AVM spec The rules every module must satisfy AVM docs / GitHub Why interfaces are consistent and trustworthy
Telemetry An opt-out usage marker AVM emits per deployment A nested ARM deployment A harmless extra deployment you can disable

Resource modules vs pattern modules

The first decision with AVM is always which kind of module. Get this right and the rest of the template writes itself; get it wrong and you either re-implement a solution AVM already ships, or you bolt a heavyweight pattern onto a job that needed one resource.

A resource module owns exactly one resource and its tightly-coupled children. avm/res/storage/storage-account deploys the account plus the blob/file/queue/table services, containers, lifecycle rules, diagnostic settings, private endpoints and RBAC that belong to that account — but not the Log Analytics workspace the diagnostics point at, or the VNet the private endpoint lands in. Those are separate modules you wire together. The boundary is “one logical resource and the things that cannot exist without it.”

A pattern module owns a solution spanning several resources. avm/ptn/network/hub-networking stands up a hub VNet, gateway subnet, Azure Firewall, route tables and the wiring between them — an opinionated, WAF-aligned architecture — by calling many resource modules internally. You hand it high-level parameters (hub CIDR, spoke definitions) and it composes the pieces. Pattern modules save you the most, because the composition — the hard part to get right — is done and tested. The two compare across the dimensions that decide which to use:

Dimension Resource module (avm/res) Pattern module (avm/ptn)
Scope One logical resource + its children A multi-resource solution / architecture
Example avm/res/key-vault/vault avm/ptn/network/hub-networking
Parameters Resource-level (SKU, ACLs, soft-delete) Solution-level (hub CIDR, spoke list)
You compose Yes — you wire several together No — composition is inside the module
Reuse altitude Very high; the building block High; whole-architecture grain
When to pick Deploy one thing the right way Deploy a known architecture in one go
When to avoid When you need a whole solution When your shape differs from the pattern
Versioning maturity Many at 0.x, some 1.0+ Generally younger; check the version

The honest guidance: start with resource modules. They are the most numerous, most mature and most flexible, and composing three or four yourself (which the lab does) teaches you how AVM fits together. Reach for a pattern module when one matches your architecture closely; if your shape differs even moderately from its opinions, you will fight its parameters and composing resource modules yourself is cleaner. Never deploy a pattern module to extract a single resource — that is using a crane to hang a picture.

Naming and the registry path

Every AVM module has a predictable path you can often guess before looking it up. The structure is avm/<type>/<grouping>/<name>:

Path segment Values Example
avm Fixed prefix avm
<type> res (resource), ptn (pattern), utl (utility) res
<grouping> The Azure service family storage, key-vault, network
<name> The specific resource/solution storage-account, vault, virtual-network

So the full reference for a pinned storage-account module is br/public:avm/res/storage/storage-account:0.9.1. Groupings mirror the Azure resource-provider families; names are kebab-case. When in doubt, the AVM module index on the official site lists every published module, version and status — confirm the exact path and an existing version there rather than guessing, since a wrong path or non-existent version is the most common first-time error (covered in troubleshooting).

Consuming a resource module: the interface in detail

This is the core skill: referencing one AVM resource module and wiring its interface. The shape never changes — module <name> '<br/public path>:<version>' = { name: '<deployment-name>'; params: { … } } — and the params block is where the standardised interface lives. A minimal storage-account reference carries module-specific values (skuName, kind) and the common interface (tags); the safe defaults (TLS 1.2, public access off) are already inside the module:

module storage 'br/public:avm/res/storage/storage-account:0.9.1' = {
  name: 'deploy-storage'                       // the nested-deployment name
  params: {
    name: 'stkvavmlab${uniqueString(resourceGroup().id)}'
    skuName: 'Standard_LRS'
    kind: 'StorageV2'
    tags: { env: 'lab', owner: 'platform' }
    // minimumTlsVersion, allowBlobPublicAccess=false etc. are AVM defaults
  }
}

output storageId string = storage.outputs.resourceId
output storageName string = storage.outputs.name

The common interface parameters

These optional parameters appear on (almost) every AVM resource module with the same name and shape. Learning them once is the leverage AVM gives you — the table below is the one to memorise:

Common parameter Type What it does Typical value
name string The resource name (required, not optional) 'stkvavmlab…'
location string Region; defaults to the RG location resourceGroup().location
tags object Resource tags { env: 'prod' }
lock object A management lock on the resource { kind: 'CanNotDelete', name: 'no-delete' }
roleAssignments array RBAC assignments on the resource [{ roleDefinitionIdOrName: 'Reader', principalId: '<oid>' }]
diagnosticSettings array Wire logs/metrics to a workspace/EH/storage [{ workspaceResourceId: law.outputs.resourceId }]
managedIdentities object System- and/or user-assigned identity { systemAssigned: true }
privateEndpoints array Private endpoints for the resource [{ subnetResourceId: …, service: 'blob' }]
enableTelemetry bool AVM usage telemetry (default true) false to opt out

Because the shape is identical across modules, wiring diagnostics into a Key Vault looks exactly like wiring it into a storage account — the same diagnosticSettings array with the same workspaceResourceId key. That consistency is what makes AVM compose cleanly.

Reading the outputs

AVM modules return a standardised set of outputs, so chaining one into another is predictable — the ones you use constantly:

Common output Type What you do with it
resourceId string Pass into another module (e.g. a diagnostic target, a private DNS link)
name string Display, or build dependent names
resourceGroupName string Cross-reference in multi-RG templates
location string Propagate region to dependents
systemAssignedMIPrincipalId string Grant this identity RBAC elsewhere
privateEndpoints array Inspect the created PE details

Putting the common interface to work — a storage account with a system-assigned identity, a lock, diagnostics and an RBAC assignment, all via the standard parameters — looks like this:

module storage 'br/public:avm/res/storage/storage-account:0.9.1' = {
  name: 'deploy-storage'
  params: {
    name: 'stappavm${uniqueString(resourceGroup().id)}'
    skuName: 'Standard_ZRS'
    managedIdentities: { systemAssigned: true }
    lock: { kind: 'CanNotDelete', name: 'lock-storage' }
    diagnosticSettings: [ { workspaceResourceId: law.outputs.resourceId } ]
    roleAssignments: [
      { roleDefinitionIdOrName: 'Storage Blob Data Reader', principalId: readerObjectId, principalType: 'User' }
    ]
    tags: { env: 'prod' }
  }
}

Each block — diagnostics, lock, identity, RBAC — would have been a separate hand-written resource in a hand-rolled template, each its own chance of a mistake. AVM collapses them into named interface parameters with vetted implementations.

Pinning and upgrading versions

The version after the colon is load-bearing. The discipline:

Rule Why What it looks like
Always pin an exact version No silent infra change on git pull …/vault:0.9.1, never a range
Pin the same version across repos Estate consistency Centralise the version in a shared var/param file
Read the changelog before bumping 0.x minors can break Check the module’s release notes on GitHub
Run what-if after any bump See the real delta before applying az deployment group what-if …
Treat 0.x minor bumps as breaking Pre-1.0 semver allows it Test in dev before test/prod
Bump deliberately, in a PR The change is reviewable One commit: “bump avm storage 0.9.0→0.9.1”

The practical workflow: keep AVM versions visible (a versions.bicep var or a comment block atop main.bicep), bump one module at a time, run what-if, review the diff in a PR, and promote dev → test → prod. Because Bicep caches modules locally, a build after a bump re-downloads only the new version; CI should cache the module cache to avoid re-pulling every run.

Composing modules into your own pattern

The real power shows up when you compose several AVM resource modules into one template — a Log Analytics workspace, a Key Vault that sends diagnostics to it, and a storage account that also reports there, all wired by passing outputs into inputs. This is exactly what a pattern module does internally; doing it yourself is both the lab and the clearest way to understand AVM. The principle is ordinary Bicep: a module’s outputs.resourceId becomes the next module’s input, and Bicep infers the deployment order from those references — you do not write dependsOn. A three-module composition:

// Deploy a workspace, then a vault and a storage account that both report to it.
param location string = resourceGroup().location

module law 'br/public:avm/res/operational-insights/workspace:0.9.0' = {
  name: 'deploy-law'
  params: {
    name: 'law-avm-${uniqueString(resourceGroup().id)}'
    location: location
  }
}

module vault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: 'deploy-vault'
  params: {
    name: 'kv-avm-${uniqueString(resourceGroup().id)}'
    location: location
    enableRbacAuthorization: true
    enablePurgeProtection: true
    diagnosticSettings: [
      { workspaceResourceId: law.outputs.resourceId }   // ← output → input
    ]
  }
}

module storage 'br/public:avm/res/storage/storage-account:0.9.1' = {
  name: 'deploy-storage'
  params: {
    name: 'stavm${uniqueString(resourceGroup().id)}'
    location: location
    skuName: 'Standard_LRS'
    diagnosticSettings: [
      { workspaceResourceId: law.outputs.resourceId }   // ← same workspace
    ]
  }
}

output workspaceId string = law.outputs.resourceId
output vaultUri string = vault.outputs.uri
output storageName string = storage.outputs.name

Three resources, fully WAF-aligned, wired together, in under forty readable lines — and not a single hand-typed minimumTlsVersion or enableSoftDelete. Note how the diagnosticSettings interface is identical on the vault and the storage account; that is the standardised interface paying off.

Composition patterns and their trade-offs

There is more than one way to assemble AVM modules; the right choice depends on reuse and team boundaries:

Composition style What it is When to use it Trade-off
Flat main.bicep All AVM modules in one template Small solution, one team Grows long; one big deployment
Your own wrapper module Wrap AVM modules in a local module you reuse Same cluster recurs across repos One more layer of indirection
Consume an AVM pattern module Let AVM compose for you Your architecture matches the pattern Less control; pattern’s opinions
Publish your own pattern to ACR Share your composition org-wide Many teams need your shape Publish/version workflow to maintain

The senior pattern worth naming is the wrapper module: when your organisation has an opinionated shape AVM does not ship — “every app gets a vault + storage + workspace wired our way” — wrap the AVM resource modules in your own module, hard-code your choices, expose only what varies, and optionally publish it to your private ACR. You get AVM’s vetted implementations underneath and your organisation’s opinions on top — modularise-what-recurs, one level up.

Overriding a default and extending a module

AVM defaults are good, not gospel. Override a default by simply passing its parameter — every default is exposed as one. To disable purge protection in a throwaway dev vault, for instance:

module vault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: 'deploy-vault'
  params: {
    name: 'kv-dev-${uniqueString(resourceGroup().id)}'
    enablePurgeProtection: false    // override: dev-only, so teardown is clean
    softDeleteRetentionInDays: 7     // override the default retention
  }
}

Add a resource the module does not expose — for something outside the module’s boundary, deploy a sibling resource (or module) and wire it via the AVM module’s outputs. You do not fork; you compose around it:

// AVM deploys the vault; you add a secret alongside it using the vault's output.
resource extraSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
  name: '${vault.outputs.name}/connstring'
  properties: { value: connectionString }   // value from a @secure() param
}

The decision table for when to do which:

Situation Do this Do NOT do this
Change a setting the module exposes Pass the parameter Fork the module
Need a child resource the module manages Use the module’s array param (e.g. secrets, containers) Add a sibling resource
Need something outside the module’s scope Sibling resource/module wired via outputs Edit the AVM source
The module is missing a capability you need Open an AVM GitHub issue / contribute Permanently maintain a private fork

The AVM specification and contribution path

You need not contribute to consume AVM well, but understanding the specification is what lets you trust the modules. AVM is governed by a published spec — a numbered set of requirements every module satisfies before publication — telling you what you can rely on across all modules. The pillars:

Spec pillar What it mandates Why it matters to you
Consistent interface Same common parameters/outputs across modules Learn the interface once, drive any module
WAF alignment Secure, reliable defaults per Well-Architected Defaults are safe; you opt out, not in
Semantic versioning MAJOR.MINOR.PATCH, published per release Predictable upgrades; pin and bump deliberately
Telemetry An opt-out usage marker per deployment A harmless extra deployment; can disable
Testing Every module ships automated tests The module is validated before publish
Ownership A named owner (often the product group) A real maintainer behind the module
Documentation Auto-generated README with every parameter The interface is always documented

The telemetry detail surprises people: by default a module emits a tiny extra nested deployment whose name encodes a usage GUID (a pid-… name) so Microsoft can measure adoption. It deploys nothing real, costs nothing, and is opt-out per module via enableTelemetry: false — an unexpected pid-… deployment is this, not a rogue resource.

The module lifecycle, so a version number tells you something:

Stage Version range What it means for consumption
Proposed / orphaned (no published version) Not consumable yet; do not reference
Initial release 0.1.0+ Usable but interface may change on minors
Maturing 0.x Stable-ish; still read minor-bump notes
GA 1.0.0+ Normal semver; only majors break interface

Contributing a module or a fix

The contribution path is more involved than consumption, but the shape is worth knowing. AVM lives in public GitHub repositories (separate ones for Bicep and Terraform); the flow:

Step What happens Who
1. Propose Open an issue for a new module or change Anyone
2. Claim ownership A named owner takes the module Contributor / product group
3. Author to spec Build the module satisfying every mandatory requirement Owner
4. Add tests Ship the required automated tests Owner
5. Pass CI The AVM pipeline validates spec compliance Automated
6. Review AVM core team reviews Maintainers
7. Publish A semantic version lands in the public registry Automated on merge

For most teams the realistic contributions are filing precise issues when a module lacks a parameter (far better than a private fork) and contributing a small fix via the repo’s contribution guide. Even if you never contribute, knowing the modules are spec-validated, tested and owned is what justifies trusting them in production — and if you need a private variant, publish your own module to your own ACR rather than forking, via the Bicep registry workflow.

Architecture at a glance

The diagram below is the picture this article builds toward, read left to right. On the far left is the source: your single main.bicep whose module lines reference AVM by pinned br/public: paths, plus a bicepconfig.json defining registry aliases and a .bicepparam per environment. That template points at the public registry (MCR) — the Microsoft endpoint br/public: resolves to — where the AVM resource modules (avm/res/...) and pattern modules (avm/ptn/...) live with their semantic versions. On first build, Bicep restores the exact pinned versions from MCR and caches them locally; this is the one hop that needs network and where a wrong path or non-existent version fails fast.

From there the flow reaches Azure Resource Manager, which expands each AVM module into a nested deployment and provisions the resources in dependency order — a Log Analytics workspace, a Key Vault and a storage account, each already WAF-aligned (TLS 1.2, public access off, diagnostics wired) because the defaults live inside the modules. The numbered badges mark the four places this goes wrong — an unpinned or non-existent version, a registry restore/auth failure, an interface mismatch after a version bump, and a private-endpoint wiring mistake — each mapped in the legend to how you confirm and fix it.

Architecture diagram: a single Bicep source — main.bicep referencing AVM modules by pinned br/public paths, a bicepconfig.json with registry aliases, and per-environment bicepparam files — restoring exact module versions from the public Microsoft Container Registry where AVM resource modules and pattern modules live, then flowing into Azure Resource Manager which expands each module into a nested deployment and provisions a WAF-aligned Log Analytics workspace, Key Vault and storage account, with numbered failure badges on the unpinned-version, registry-restore, interface-mismatch and private-endpoint-wiring hops.

Real-world scenario

Meridian Health, a mid-sized health-tech company, ran twelve application teams against a shared Azure platform, each hand-authoring its own Bicep. The platform team’s quarterly security review kept surfacing the same findings: three storage accounts with public blob access still enabled, two Key Vaults without purge protection, five resources with no diagnostics wired to the central workspace, and a long tail of templates on an old storage API version that predated a hardening. None of it was malice — each gap was a copy-pasted block that had drifted, and with a dozen teams there was no single place to fix any of it.

The platform team adopted AVM over one quarter, starting with the three most-deployed resources — storage account, Key Vault and Log Analytics workspace. They built one internal wrapper module per resource that referenced the corresponding AVM module at a pinned version, hard-coded Meridian’s mandatory choices (every resource sends diagnostics to the central workspace; every prod storage account is ZRS; every vault has purge protection on), and exposed only the parameters teams legitimately varied (name, environment, SKU from an allowed list). They published those wrappers to the platform team’s private ACR under a br/meridian: alias, so teams referenced br/meridian:storage:1.0.0 and got AVM’s vetted implementation with Meridian’s opinions on top.

The numbers told the story. The next review found zero storage accounts with public access and zero vaults without purge protection — not because anyone audited harder, but because those settings now lived inside a versioned module no parameter file could override. Diagnostic coverage of the three types went from ~70% to 100%, because the wrapper wired the workspace automatically. When a new storage capability (an extended soft-delete window) landed, they bumped the underlying AVM version once, tested in dev, ran what-if across the consuming repos, and rolled it out by publishing br/meridian:storage:1.1.0 — one change, not twelve. The platform lead summarised it: “we stopped reviewing whether each team configured storage securely and started reviewing whether they pinned the right version.”

The one pain point: the first Key Vault bump was a minor version on a 0.x module that changed a parameter name — a breaking change inside a minor, exactly the pre-1.0 semver behaviour. Their dev deployment failed at build with a clear “unexpected parameter” error; they read the release notes, adjusted the one parameter, and promoted cleanly. That single failure taught the team the most important AVM discipline (covered in troubleshooting): treat every 0.x minor bump as potentially breaking and always what-if before promoting.

Advantages and disadvantages

AVM is a strong win for any estate past one repo, but it is not free — you trade hand-authoring control for a dependency on Microsoft’s modules and their versioning cadence.

Advantages Disadvantages
Secure, WAF-aligned defaults baked in — the safe value is the default A dependency on Microsoft’s release cadence and decisions
One vetted implementation instead of N drifting copies Pre-1.0 modules can break the interface on a minor bump
Standardised interface — learn once, drive any module Less control than hand-authoring; opinions you may not share
Maintained centrally; you bump a version, not 12 templates A wrong path / non-existent version fails the build until fixed
Composes cleanly (output → input) into your own patterns Reading an AVM module’s source to debug is a real skill curve
Auto-documented, tested, owned — defensible in audit Registry restore needs network/cache; CI must cache the modules
Free to consume; the public registry needs no login Telemetry deployment surprises people (harmless, opt-out)

When each matters: the advantages dominate the moment you have more than one team, repo or environment that must stay consistent and secure — most real organisations. The disadvantages bite mainly during upgrades (the 0.x-minor-can-break reality means you cannot bump blindly) and at the edges (when your shape differs from what a module exposes and you compose around it). For a one-off sandbox resource a three-line hand-written resource is still fine; reach for AVM when consistency, security posture and maintainability matter — which, past the first project, they always do.

Hands-on lab

This is the centrepiece. You will consume an AVM resource module two ways (portal and az CLI), compose three AVM modules into one template, deploy it, validate the WAF-aligned defaults are really there, override a default, and tear it down. Everything is free-tier-friendly — workspace, vault and storage cost effectively nothing at rest — and runs in Cloud Shell or any terminal with az. Do it once end to end and AVM stops being abstract.

Step 0 — Prerequisites and environment check

Confirm your tooling before you write a line. You need az 2.40.0+ (which bundles a recent Bicep CLI that can restore registry modules) and Contributor on a subscription or resource group.

az version                       # az >= 2.40.0
az bicep version                 # Bicep CLI present; if not: az bicep install
az account show -o table         # confirm the right subscription is active

Expected output: az version prints JSON with "azure-cli": "2.4x" or newer; az bicep version prints something like Bicep CLI version 0.3x.x; az account show shows your subscription and State: Enabled. If az bicep version errors, run az bicep install. If your az is below 2.40, upgrade it — older Bicep CLIs have weaker registry-restore behaviour.

Create a working folder and a resource group for the lab.

mkdir -p avm-lab && cd avm-lab
az group create --name rg-avm-lab --location eastus -o table

Expected output: a table row with Name: rg-avm-lab and ProvisioningState: Succeeded.

Step 0 check Command Pass looks like
CLI version az version azure-cli ≥ 2.40.0
Bicep present az bicep version a 0.3x version string
Right subscription az account show -o table the intended sub, Enabled
RG created az group create … ProvisioningState: Succeeded

Step 1 — Find the module and confirm a real version

Before referencing anything, confirm the exact path and an existing version. The authoritative source is the AVM module index on the official site, but you can also list the published tags of a module straight from the registry — these are the versions you are allowed to pin:

# List published versions (tags) of the storage-account resource module in MCR.
az acr repository show-tags \
  --name mcr.microsoft.com \
  --repository bicep/avm/res/storage/storage-account \
  --orderby time_desc -o table

Expected output: a column of semantic-version tags, newest first (e.g. 0.9.1, 0.9.0, 0.8.0, …). Pick a real one — this lab uses 0.9.1 as an example; use a version the command actually returns, since the registry moves on over time. The habit this step builds: never invent a version, always confirm the path and a tag exist before referencing them (a wrong path fails the build; a non-existent version fails the restore).

Step 2 — Consume one AVM module (Bicep + az CLI)

Create storage.bicep referencing the AVM storage-account module by a pinned version. The whole point: you type the values that vary, AVM supplies the secure defaults.

// storage.bicep — one AVM resource module, pinned version
param location string = resourceGroup().location

module storage 'br/public:avm/res/storage/storage-account:0.9.1' = {
  name: 'deploy-storage'
  params: {
    name: 'stavmlab${uniqueString(resourceGroup().id)}'
    location: location
    skuName: 'Standard_LRS'
    kind: 'StorageV2'
    tags: { env: 'lab', purpose: 'avm-demo' }
  }
}

output storageId string = storage.outputs.resourceId
output storageName string = storage.outputs.name

First, build it — this triggers the registry restore (the first build downloads and caches the module). If the path or version is wrong, this is where it fails, fast, before any deployment:

az bicep build --file storage.bicep

Expected output: no errors, and a storage.json (transpiled ARM) appears beside storage.bicep. On first run you may see a one-time “Restoring…” message as Bicep pulls the module from MCR. If you get Unable to restore or not found, re-check the path and version from Step 1.

Now preview with what-if, then deploy:

az deployment group what-if \
  --resource-group rg-avm-lab \
  --template-file storage.bicep

az deployment group create \
  --resource-group rg-avm-lab \
  --template-file storage.bicep \
  --name avm-storage

Expected output: what-if lists a + Microsoft.Storage/storageAccounts create (plus the AVM telemetry nested deployment); create ends with "provisioningState": "Succeeded" and an outputs block containing your storageId and storageName.

Step 2 check Command Pass looks like
Module restored & built az bicep build --file storage.bicep no error; storage.json created
Preview shows one storage create … what-if + Microsoft.Storage/storageAccounts
Deploy succeeds … create provisioningState: Succeeded
Outputs returned (in the create output) storageName, storageId present

Step 3 — Consume the same module from the portal

The portal cannot consume a .bicep file with a registry reference directly, but it consumes the transpiled ARM JSON you just built. This is how a team that prefers a UI still benefits from AVM:

  1. In the portal search bar, type Deploy a custom template and open it.
  2. Choose Build your own template in the editor.
  3. Click Load file and select the storage.json produced by az bicep build in Step 2 (it already has the AVM module inlined as a nested deployment).
  4. Click Save, then on the Custom deployment blade choose Subscription and Resource group rg-avm-lab, set any exposed parameters, and click Review + create.
  5. After validation passes, click Create and wait for Your deployment is complete.

Expected output: the deployment blade shows Deployment succeeded, with the storage account and the AVM nested deployment(s) listed under Deployment details. The key learning: AVM is consumed in the portal via the built ARM, because the portal has no concept of a Bicep registry — az bicep build is the bridge.

Portal step Where Pass looks like
Open custom template Search → Deploy a custom template The editor blade opens
Load built JSON Load file → storage.json Template populates
Target RG Custom deployment blade rg-avm-lab selected
Deploy Review + create → Create “Deployment succeeded”

Step 4 — Compose three AVM modules into one template

Now the real value: compose a workspace, a vault and a storage account, wiring diagnostics from the latter two into the former by passing outputs into inputs. Create main.bicep:

// main.bicep — compose three AVM resource modules, wired by outputs
param location string = resourceGroup().location

module law 'br/public:avm/res/operational-insights/workspace:0.9.0' = {
  name: 'deploy-law'
  params: {
    name: 'law-avm-${uniqueString(resourceGroup().id)}'
    location: location
  }
}

module vault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: 'deploy-vault'
  params: {
    name: 'kv-avm-${uniqueString(resourceGroup().id)}'
    location: location
    enableRbacAuthorization: true
    enablePurgeProtection: true
    diagnosticSettings: [
      { workspaceResourceId: law.outputs.resourceId }
    ]
  }
}

module storage 'br/public:avm/res/storage/storage-account:0.9.1' = {
  name: 'deploy-storage'
  params: {
    name: 'stavm${uniqueString(resourceGroup().id)}'
    location: location
    skuName: 'Standard_LRS'
    diagnosticSettings: [
      { workspaceResourceId: law.outputs.resourceId }
    ]
  }
}

output workspaceId string = law.outputs.resourceId
output vaultUri string = vault.outputs.uri
output storageName string = storage.outputs.name

Build, preview and deploy:

az bicep build --file main.bicep
az deployment group what-if --resource-group rg-avm-lab --template-file main.bicep
az deployment group create --resource-group rg-avm-lab \
  --template-file main.bicep --name avm-compose

Expected output: what-if shows three resource creates (workspace, vault, storage) plus the modules’ nested deployments; create ends Succeeded and returns workspaceId, vaultUri, storageName. Because the vault and storage diagnosticSettings reference law.outputs.resourceId, Bicep deploys the workspace first automatically — you wrote no dependsOn. (The three pins are illustrative; run the Step-1 tag command and pin versions that exist today.)

Step 4 check Command Pass looks like
Composition builds az bicep build --file main.bicep no error; main.json created
Preview shows 3 creates … what-if workspace + vault + storage + lines
Deploy succeeds … create Succeeded; three outputs returned
Order inferred (no dependsOn written) workspace deploys before its consumers

Step 5 — Validate the WAF-aligned defaults are really there

AVM’s promise is that the secure defaults are in the module. Prove it — query the deployed storage account and confirm TLS 1.2 and public-access-off were applied without you typing them:

ST=$(az deployment group show -g rg-avm-lab -n avm-compose \
  --query properties.outputs.storageName.value -o tsv)

az storage account show -g rg-avm-lab -n "$ST" \
  --query "{tls:minimumTlsVersion, publicBlob:allowBlobPublicAccess, https:supportsHttpsTrafficOnly}" -o table

Expected output: a table showing minimumTlsVersion: TLS1_2, allowBlobPublicAccess: False, supportsHttpsTrafficOnly: True — none of which you set in your template. That is the WAF-aligned default living inside the AVM module. Do the same sanity check on the vault’s purge protection:

KV=$(az keyvault list -g rg-avm-lab --query "[0].name" -o tsv)
az keyvault show -n "$KV" --query "properties.enablePurgeProtection" -o tsv   # → true
Validation Command Pass value Who set it
TLS minimum az storage account show … minimumTlsVersion TLS1_2 AVM default
Public blob access … allowBlobPublicAccess False AVM default
HTTPS-only … supportsHttpsTrafficOnly True AVM default
Vault purge protection az keyvault show … enablePurgeProtection true You (explicit)

Step 6 — Override a default, then re-deploy

Defaults are overridable. Add an override to the storage module to prove the parameter is yours to set — for example, pin the access tier and add a tag, then redeploy and confirm the delta with what-if first:

// in main.bicep, extend the storage module params:
    accessTier: 'Cool'           // override: default is usually Hot
    tags: { env: 'lab', tier: 'cool' }
az deployment group what-if --resource-group rg-avm-lab --template-file main.bicep

Expected output: what-if shows a ~ Microsoft.Storage/storageAccounts modify line changing accessTier from Hot to Cool (and the new tag) — a precise, reviewable delta. Apply it with create again and re-run the Step-5 validation to confirm only what you intended changed. The lesson: every AVM default is a parameter, and what-if shows you exactly what an override moves before you commit.

Step 7 — Tear down

Delete the resource group to remove everything in one shot (storage, vault, workspace and all nested deployments). The vault has purge protection on from Step 4, so it is soft-deleted and retained — that is the protection working as designed; it incurs no cost while soft-deleted and ages out per the retention window.

az group delete --name rg-avm-lab --yes --no-wait

Expected output: the command returns immediately (--no-wait); the RG enters Deleting. Confirm it is gone with az group exists --name rg-avm-lab (eventually false).

Teardown step Command Pass looks like
Delete the RG az group delete … --yes --no-wait returns immediately; RG Deleting
Confirm removal az group exists --name rg-avm-lab false once complete
Note the soft-deleted vault az keyvault list-deleted -o table the vault listed (purge-protected)

If you must reclaim the vault name before the retention window ends and purge protection is off, you would az keyvault purge; with purge protection on (as here) you cannot purge — it must age out, which is the deliberate safety behaviour, not a bug.

Common mistakes & troubleshooting

These are the failures every team hits when adopting AVM — symptom, root cause, how to confirm, and the fix. Most surface either at build/restore time (fast, before deploy) or as an ARM error at deploy time (slower).

# Symptom Root cause Confirm with Fix
1 Unable to restore … not found at build Wrong path or a version that doesn’t exist az acr repository show-tags … --repository bicep/avm/res/<svc>/<res> Use the exact path + a real version from the tag list
2 Build wants a version but you wrote a range/latest Bicep registry refs require an exact version The module line has no concrete :x.y.z Pin an exact MAJOR.MINOR.PATCH
3 unexpected parameter 'X' after a version bump A 0.x minor bump changed the interface (breaking) Compare your params to the new module README/changelog Read release notes; rename/adjust the parameter
4 The Resource … was not found for a referenced output A composed module’s output name changed or you typo’d it az bicep build error names the missing output Use the exact output (resourceId, uri, …) from the module README
5 Storage/vault name invalid Name violates the resource’s length/charset rule Error: name … is not valid; check the rule Storage 3–24 lowercase alnum; trim prefix + uniqueString
6 Private endpoint deploy fails Wrong subnetResourceId, or missing private DNS zone wiring what-if/deploy error on the PE; check the subnet exists Pass a real subnet resourceId; wire the privateDnsZoneGroup
7 First CI build is slow / flaky on restore The Bicep module cache is not persisted between CI runs CI logs show “Restoring” every run Cache ~/.bicep (module cache) in the pipeline
8 Unexpected pid-… deployment in history AVM telemetry nested deployment az deployment group list shows a pid-… name Harmless; set enableTelemetry: false to suppress
9 MSI/auth error restoring from a private registry Consuming your own ACR module without login/RBAC Restore error mentions the ACR endpoint az acr login / grant AcrPull; public br/public: needs none
10 Override “doesn’t apply” You passed a parameter the module version doesn’t expose The value is unchanged after deploy; not in module README Confirm the parameter exists in that version; bump if needed
11 what-if shows a giant replace on a no-op bump A patch bump changed a property AVM now manages Read the ~/-/+ lines in what-if Verify it’s intended; some bumps re-baseline a property
12 Deployment exceeds 800 resources A pattern module (or your big composition) created >800 Failed deployment’s resource count Split into separate module deployments (each = fresh 800)

The single most important real-world failure is #3 — a breaking change inside a 0.x minor version. Pre-1.0, semantic versioning permits the interface to change on a minor bump, and AVM uses that latitude. The discipline that prevents the 2 a.m. surprise: pin exact versions, bump one module at a time in a pull request, read the module’s release notes, and run what-if in dev before promoting to test/prod. The reference rules worth memorising:

AVM consumption rule Value / behaviour Why
Version reference Exact MAJOR.MINOR.PATCH, no ranges Bicep registry refs forbid floating
Pre-1.0 minor bumps May be breaking Semver allows interface change pre-1.0
1.0+ bumps Only MAJOR breaks the interface Normal semver applies after GA
Public registry auth None required (anonymous pull) br/public: is anonymously readable
Private registry auth AcrPull / az acr login Your own ACR is access-controlled
Resources per deployment 800 (incl. module-expanded resources) ARM limit; split into modules
Telemetry On by default, opt-out per module Adoption measurement; harmless

Best practices

Security notes

AVM is, before anything else, a security control: it moves the secure default from “something each engineer must remember to type” to “something baked into a versioned, owned module.” The Meridian regression — public blob access slipping back via a stale copied block — cannot recur when allowBlobPublicAccess: false lives inside the AVM module rather than a hand-written block a parameter file can override.

Concern Risk with hand-authored Bicep The AVM fix
Insecure defaults drift A stale copied block re-enables public access / old TLS WAF-aligned defaults live inside the module
Secrets in templates Hard-coded secrets; secrets in outputs Use @secure() params; never output a secret; vault getSecret()
Inconsistent RBAC/diagnostics Often skipped; re-discovered in prod First-class roleAssignments / diagnosticSettings interface
Private networking forgotten Public endpoint left on in one of N templates privateEndpoints interface; wrap to make it mandatory
Supply-chain trust Copy-pasted code of unknown provenance Spec-validated, tested, owned, versioned modules
Over-broad deploy identity Pipeline SPN with Owner everywhere Scope deployments to the RG; least-privilege the identity
Unpinned dependency “Latest” silently changes infra Exact version pin; bump is a reviewed PR

Three specifics. First, the deployment identity: AVM does not change who deploys — a pipeline still needs an identity with rights at the target scope, so grant the minimum (Contributor on the target RG, not Owner on the subscription); see Managed Identity: System vs User-Assigned Patterns. Second, defence in depth: AVM defaults encourage the secure posture, but an Azure Policy with a deny effect on public blob access is the platform backstop that catches anything a deliberately-overridden default would let through — template plus policy. Third, private modules: an ACR hosting your own modules is an access-controlled supply-chain asset — restrict AcrPull and treat it with the rigor in Azure Container Registry: Secure Supply Chain.

Cost & sizing

AVM itself has no cost — the modules are authoring artefacts from a free public registry, and ARM deployments are free. The bill comes entirely from the resources the modules deploy, which is where AVM’s interface helps: a parameter change right-sizes SKU, tier and redundancy per environment in one line, and an if-gated module keeps prod-only resources out of dev.

Cost driver Where it shows up How AVM helps Rough figure
Consuming AVM The registry pull Free; public, anonymous ₹0 / $0
Resource SKU/tier per env The deployed resource Pass the SKU parameter per env Storage LRS vs ZRS: ZRS ~25% more
Diagnostics ingestion Log Analytics ingestion + retention The diagnosticSettings interface wires it; you size retention ~₹230–300/GB ingested (~$2.76/GB)
Purge-protected vault Soft-deleted vault retained No charge while soft-deleted ₹0 at rest; ages out per retention
Private endpoints Per-PE hourly + data privateEndpoints interface, condition for prod only ~₹600–800/mo (~$7–10) each
Pattern-module solutions The whole architecture it deploys One reference; cost is the sum of resources Sum of the resources it stamps
CI restore Network egress (negligible) Cache the module cache ~₹0; just build time

Right-sizing rule of thumb: let the parameter file carry the cost knobs. Dev’s .bicepparam passes the cheapest SKUs (LRS, no private endpoint, short retention) and prod’s the hardened ones (ZRS, private endpoints, longer retention) — the difference is reviewed data, not forked code. The largest controllable line item is usually Log Analytics ingestion, which the diagnosticSettings interface wires but does not size for you — set sensible retention and skip verbose categories you never query. For the storage SKU economics the parameter chooses between, Azure Storage Redundancy: LRS, ZRS, GRS, RA-GRS Explained breaks down the per-GB rates.

Interview & exam questions

These map to AZ-104 (Azure Administrator — IaC/Bicep deployment objectives) and AZ-204 (Developer — provisioning with Bicep). Each is a question and a model answer.

1. What is Azure Verified Modules and why use it? AVM is Microsoft’s single official library of pre-built, WAF-aligned Bicep and Terraform modules consumed from a public registry by version. It gives you secure defaults baked in, one vetted implementation instead of drifting copies, and central maintenance — you bump a version rather than editing every template.

2. What is the difference between a resource module and a pattern module? A resource module (avm/res/...) deploys one WAF-aligned logical resource and its tightly-coupled children (e.g. avm/res/key-vault/vault). A pattern module (avm/ptn/...) deploys a multi-resource solution by composing resource modules internally (e.g. a hub-spoke network). Use resource modules to deploy one thing right; use a pattern module to deploy a whole known architecture.

3. How do you reference an AVM module in Bicep, and why must you pin a version? With a module declaration whose source is br/public:avm/res/<svc>/<res>:<MAJOR.MINOR.PATCH>. You must pin an exact version because Bicep registry references forbid ranges/“latest”, and pinning stops a git pull from silently changing what your infrastructure deploys — you upgrade deliberately.

4. Why is a 0.x minor version bump potentially dangerous? Under semantic versioning, before 1.0.0 the interface is allowed to change on a minor bump, and AVM uses that latitude. So a 0.8.0 → 0.9.0 bump can be breaking — you must read the release notes, test in dev, and run what-if before promoting, rather than assuming minors are safe.

5. What is the AVM common interface? A standardised set of optional parameters that appear with the same name and shape on (almost) every resource module — tags, lock, roleAssignments, diagnosticSettings, managedIdentities, privateEndpoints, enableTelemetry — plus standardised outputs like resourceId and name. Learning it once lets you drive any AVM module.

6. How do you compose AVM modules, and how is deployment order determined? Reference several modules in one template and pass one module’s outputs.resourceId (or other output) into another’s input parameter. Bicep infers the dependency order from those references — you do not write dependsOn. This is exactly what a pattern module does internally.

7. Does consuming public AVM modules require authentication? No. The public AVM modules live in the Microsoft Container Registry and are anonymously pullable via the br/public: alias — no az acr login needed. Authentication (AcrPull) is only required when you consume your own modules from a private ACR.

8. What is AVM telemetry and how do you disable it? By default each AVM module emits a small additional nested deployment (a pid-… name) so Microsoft can measure adoption; it deploys nothing real and costs nothing. You opt out per module with enableTelemetry: false.

9. How do you consume an AVM module from the portal, which has no registry concept? Build the Bicep to ARM JSON with az bicep build (which inlines the AVM module as a nested deployment), then use Deploy a custom template → Build your own template → Load file with the produced .json. The portal consumes the built ARM, not the .bicep.

10. How do you override an AVM default, and when should you fork instead? Pass the corresponding parameter — every AVM default is exposed as one. You should essentially never fork: for a managed child resource use the module’s array parameter, for something outside its scope compose a sibling resource via outputs, and if a capability is missing, file an AVM issue or publish your own wrapper module.

11. What governs AVM modules and why does that matter for trust? A published specification of mandatory requirements (consistent interface, WAF alignment, semantic versioning, automated testing, named ownership, auto-generated docs). Because every module is spec-validated, tested and owned, you can defensibly trust them in production rather than auditing copy-pasted blocks.

12. Your AVM template fails with “exceeded the limit of 800 resources.” What happened, and the fix? A large composition or pattern module expanded past 800 resources, and ARM caps a single deployment at 800. Split the work across separate module deployments — each module is its own nested deployment with a fresh 800-resource budget.

Quick check

  1. You need a storage account that already has TLS 1.2 and public blob access disabled without typing those settings. What do you reference, and what prefix and version discipline applies?
  2. A reviewer asks why avm/res/key-vault/vault is a resource module but a hub-spoke network is a pattern module. What is the distinction?
  3. You bumped an AVM module from 0.9.0 to 0.10.0 and the build now errors with “unexpected parameter.” Why is a minor bump allowed to break, and what is the safe upgrade routine?
  4. You composed three AVM modules and wrote no dependsOn, yet the workspace deploys before the vault. How did Bicep know the order?
  5. Consuming br/public: needs no login, but consuming br/contoso:mymodule:1.0.0 fails with an auth error. Why, and what is the fix?

Answers

  1. Reference the AVM storage-account resource module, br/public:avm/res/storage/storage-account, pinned to an exact MAJOR.MINOR.PATCH version (no ranges/“latest”). The secure defaults live inside the module, so you only pass the values that vary.
  2. A resource module deploys one logical resource and its tightly-coupled children; a pattern module deploys a multi-resource solution by composing several resource modules internally. The Key Vault is one resource; a hub-spoke network is a whole architecture.
  3. Before 1.0.0, semantic versioning allows the interface to change on a minor bump, and AVM uses that. The safe routine: read the release notes, adjust the changed parameter, test in dev, run what-if, then promote dev → test → prod one module at a time in a reviewed PR.
  4. Bicep inferred the dependency from the data flow: the vault’s diagnosticSettings references law.outputs.resourceId, so the workspace must exist first. References between modules create the order automatically — no dependsOn needed.
  5. br/public: points at the anonymously-pullable public Microsoft Container Registry; br/contoso: is your own access-controlled ACR. Fix it by authenticating — az acr login --name contoso (or granting the deploy identity AcrPull) before the build.

Glossary

Next steps

AzureBicepAzure Verified ModulesAVMInfrastructure as CodeBicep RegistryModulesaz CLI
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading