Composability in Azure Verified Modules

6 min read

The earlier post on adopting Azure Verified Modules as an enterprise IaC standard covered the trade-off decision — what you gain, what you give up, why treating AVM as a foundation for your own module library beats consuming it directly everywhere. What that post didn’t get into is the thing that actually makes the “foundation” framing hold up: composability in AVM isn’t an emergent property of well-written modules. It’s a rule in the specification itself, enforced the same way across every module regardless of who owns it.

Composability is a spec rule, not a convention

Two rules in the AVM specification do the actual work here. The first, cross-referencing modules, says a module MAY reference other modules to build itself, but only through a public registry reference to a pinned version — br/public:avm/[res|ptn|utl]/<name>:<version> — never a local path reference, and never a reference to anything outside the AVM catalog. The second says pattern modules must be assembled from resource modules, and may also be assembled from other pattern modules, with the same restriction: AVM modules compose with AVM modules, nothing else.

That sounds like a small technical detail, but it’s what makes AVM usable as a foundation rather than just a pile of individually-useful modules. Every module, from every module owner, composes with every other module through the same mechanism — a versioned registry reference — not through whatever ad hoc convention a given team invented. That consistency is exactly what a wrapper layer around AVM depends on to stay simple.

The Container Apps split: deliberately incomplete resource modules

Azure Verified Modules doesn’t ship a single “container app” module that also creates the environment it runs in. It ships two separate resource modules: avm/res/app/container-app, which models the container app itself, and avm/res/app/managed-environment, which models the environment separately. The container app module explicitly does not include the environment — it requires an environmentResourceId pointing at one that already exists.

That split is deliberate, not an oversight. An environment is a shared, longer-lived boundary that can host any number of container apps over its lifetime — a resource module can’t assume in advance how many apps will eventually attach to it, so composability requires modeling the environment and the app as genuinely separate concerns, wired together by a resource ID rather than folded into one module with an app-count parameter.

In practice, that wiring looks like this:

module environment 'br/public:avm/res/app/managed-environment:<version>' = {
  params: {
    name: 'ame-shared-001'
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsWorkspaceResourceId: logAnalytics.outputs.resourceId
    }
    infrastructureSubnetResourceId: subnet.outputs.resourceId
    internal: true
  }
}

module containerApp 'br/public:avm/res/app/container-app:<version>' = {
  params: {
    name: 'api'
    environmentResourceId: environment.outputs.resourceId
    containers: [
      {
        name: 'main'
        image: 'mcr.microsoft.com/k8se/quickstart:latest'
        resources: {
          cpu: '0.5'
          memory: '0.5Gi'
        }
      }
    ]
  }
}

The composability mechanism in practice is exactly that one line — environmentResourceId: environment.outputs.resourceId. No glue code, no separate lookup, no custom output parsing. One module’s output is another module’s input, by design.

Worth knowing before you copy this verbatim: the shape of appLogsConfiguration isn’t guaranteed to stay put. A recent version of the managed-environment module flattened that nested block into a top-level logAnalyticsWorkspaceResourceId parameter. That’s a real, concrete example of the versioning churn the earlier AVM post warned about — exactly the kind of change a wrapper layer around these modules exists to absorb once, centrally, instead of every consuming team hitting it independently.

Extending the same pattern: identity and a registry

Two modules is the minimum, not the typical case. A container app pulling a private image needs a registry and, usually, a user-assigned managed identity to authenticate against it rather than embedding credentials. The composition pattern doesn’t change — you’re still wiring resourceId and connection outputs from one module into another’s parameters, just with two more modules in the graph: avm/res/managed-identity/user-assigned-identity and avm/res/container-registry/registry, feeding their outputs into the container app module’s identity and registry configuration. A realistic Container Apps deployment is commonly four or five AVM resource modules wired together this way — environment, registry, identity, container app, and the Log Analytics workspace underneath the environment — not because the architecture is complex, but because each concern gets its own module and the composition is what assembles them into something that actually runs.

Where composability goes further: the pattern module

If wiring four or five resource modules together for every environment sounds like exactly the kind of thing a platform team standardizes once rather than repeating, AVM already has an answer at the next layer up: avm/ptn/aca-lza/hosting-environment, a published AVM pattern module that assembles a full Container Apps landing zone hosting environment in one composition — hub-spoke virtual networks and subnets, the Container Apps environment itself, Key Vault, Log Analytics, Application Insights, and optional edge and access components: Application Gateway, Front Door, Bastion, and a jump box VM for secure access, depending on what the environment needs to expose. (Full disclosure: this is a module I created and owned back in the day as part of the AVM project, so I’m partial to it as an example — but it also happens to be the cleanest illustration of this layer of composability available.)

Where the earlier example wired two resource modules together by hand, using the pattern module collapses that entire composition into a single reference:

module hostingEnvironment 'br/public:avm/ptn/aca-lza/hosting-environment:<version>' = {
  name: 'hostingEnvironmentDeployment'
  params: {
    // Required parameters
    applicationGatewayCertificateKeyName: '<applicationGatewayCertificateKeyName>'
    deploymentSubnetAddressPrefix: '10.1.4.0/24'
    enableApplicationInsights: true
    enableDaprInstrumentation: false
    spokeApplicationGatewaySubnetAddressPrefix: '10.1.3.0/24'
    spokeInfraSubnetAddressPrefix: '10.1.0.0/23'
    spokePrivateEndpointsSubnetAddressPrefix: '10.1.2.0/27'
    spokeVNetAddressPrefixes: [
      '10.1.0.0/21'
    ]
    vmAdminPassword: '<vmAdminPassword>'
    vmJumpBoxSubnetAddressPrefix: '10.1.2.32/27'
    vmSize: 'Standard_B1s'
    // Non-required parameters
    vmAuthenticationType: 'sshPublicKey'
    vmJumpboxOSType: 'linux'
    workloadName: 'camin'
  }
}

Everything the two-module example built by hand — the environment, the Log Analytics workspace behind it, the subnet it integrates with — is in there, plus the networking and edge-access layer around it, behind one module reference and a set of parameters that read like network and workload design decisions rather than a list of resources to wire together. That’s the resource-modules-build-pattern-modules rule made concrete: everything this module provisions is itself an AVM resource module, composed the same way the earlier example composed two of them by hand, just at a larger scope. And because pattern modules can also be built from other pattern modules, this one is a legitimate building block for something bigger still — a full landing zone subscription vending pattern, for instance — without breaking the same registry-reference, pinned-version contract that held at the smallest scale.

Where composability hits a wall: ARM template size

There’s a ceiling on how far this pattern scales, and it’s worth understanding before pattern modules start looking like a clean answer to everything. A Bicep module reference isn’t a purely organizational construct — it compiles down into a nested deployment resource embedded directly inside the parent ARM template, in full. That’s true recursively: a pattern module built from five resource modules compiles into one parent template containing five embedded child templates, and if that pattern module is itself referenced inside a still-larger pattern module — which, per the specification, it’s explicitly allowed to be — the whole thing gets embedded again, one level further up.

ARM’s hard limits don’t care how the template got assembled: 4 MB for the fully-expanded template, 1 MB per individual resource definition, and a separate 1 MB ceiling on the deployment job itself after compression. Because nested templates are embedded whole rather than linked by reference, template size compounds with the depth and breadth of your composition tree, not just the number of resources visible in your own Bicep source. This is a live problem, not a theoretical one — teams working with heavily nested Bicep module structures have hit the 4 MB ceiling in production purely from the overhead that accumulates once modules are compiled and embedded, and it remains an open, unresolved category of issue for the Bicep team rather than something with a quick fix.

Practically, the risk concentrates exactly where composability is most tempting: a pattern module like the Container Apps landing zone accelerator, referenced inside a larger landing zone pattern module, referenced inside your own subscription-vending wrapper. Each layer is a legitimate, spec-compliant use of AVM’s own composition rules, and each layer also adds its full expanded size to everything above it. A composition depth that looks entirely reasonable on a whiteboard can fail at deployment time for a reason that has nothing to do with the architecture being wrong.

A few things worth planning around rather than discovering mid-deployment:

  • Track actual compiled template size during development, not just your source Bicep file’s size — the two diverge fast once you’re several composition layers deep.
  • Where a pattern module is large and stable, consider deploying it separately — as its own deployment, or as a template spec — rather than nesting it inside a still-larger composition, so its size doesn’t compound into a parent template’s budget.
  • Azure Deployment Stacks and linked-template patterns exist specifically to work around this ceiling for large, multi-team compositions — worth evaluating deliberately, rather than being forced into them by a job-size error during a production rollout.

None of this is a reason to avoid pattern modules or deep composition — it’s a reason to treat composition depth as a real architectural question with a hard numeric ceiling behind it, the same way you’d treat any other finite resource. AVM’s rules make it easy to build a clean, spec-compliant composition right up against that ceiling without anything warning you until you’re already there.

This is also, honestly, something the Bicep and ARM teams need to treat as a priority rather than a known quirk. Composability at pattern-module scale is the entire value proposition AVM is asking enterprises to build on — it’s the reason a landing zone accelerator can exist as a single referenceable module instead of thousands of lines everyone copies and drifts from independently. A template size ceiling left over from a much simpler era of ARM shouldn’t be the thing quietly capping how far that model can go before organizations start hitting it in production. Either the limit needs to move substantially, or nested deployments need a way to avoid being embedded in full at every level of composition. Until one of those happens, composition depth has to be treated as a first-class design constraint by every team building on AVM, not an edge case to discover the hard way.

What this means for your own module library

If you’re wrapping AVM modules the way the earlier post described, this is the contract your wrapper is actually built on top of. You can reference AVM resource modules directly where your governance needs are generic, reference a pattern module like the Container Apps landing zone accelerator where its shape genuinely fits your environment, or mix both — and either way, you’re composing within the same boundary AVM enforces on itself. That matters most at the edges: if you ever need to fork, you’re forking a composition, not a module family that quietly reinvented how its pieces talk to each other.

The one place this cuts the other way is worth naming plainly: an AVM module can only reference other AVM modules, never your own custom ones. If you want to extend a pattern module’s shape with something org-specific — your own tagging enforcement, a compliance control unique to your environment — that composition has to happen in your wrapper layer, above AVM, not inside a fork of the AVM module itself. That’s the price of staying able to pull upstream updates cleanly, and it’s a fair one.

The platform engineering takeaway

Composability in AVM isn’t a nice side effect of the modules being well-written. It’s an enforced rule — pinned registry references only, AVM-to-AVM only — and Container Apps is a clean way to see it operate at every scale at once: two resource modules required to compose, room for several more depending on the real workload, and a full pattern module one level up that assembles all of it into a landing zone. That’s the shape a good enterprise module library needs, at the layer you don’t have to design yourself. The one thing worth carrying forward alongside it: ARM’s 4 MB template ceiling doesn’t grant an exception for compositions that are well-designed and spec-compliant, so composition depth deserves the same deliberate scrutiny you’d give any other hard architectural limit.


Discover more from ksharp

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Discover more from ksharp

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

Continue reading