Featured image of post Azure Bicep Modules Explained: Clean, Reusable, and Ready for Scale

Azure Bicep Modules Explained: Clean, Reusable, and Ready for Scale

Learn how to turn Azure Bicep templates into clean, reusable systems using modules. This hands-on guide walks you through modular design principles, real-world examples, and best practices that scale. Build infrastructure that's easier to trust, maintain, and evolve.

Introduction: Designing for Reuse and Scalability

You’ve just written your fifth Bicep template to deploy a storage account. Different environments, same logic, slightly tweaked each time. It’s working—but it’s starting to feel messy.

That’s the creeping cost of repetition.

As your infrastructure grows, you’ll often find yourself duplicating the same blocks of code across templates: deploying a virtual network here, a storage account there, maybe wiring up logging or monitoring the same way over and over again. Every time something changes, you’re chasing it down in multiple places.

This isn’t just tedious—it fragments your codebase. It breaks consistency. It slows down your ability to scale, collaborate, or make confident changes.

That’s the problem Bicep modules are designed to solve.

Modules give you a way to define a piece of infrastructure logic once—then reuse it across environments, projects, and teams with confidence.

A module might encapsulate a full virtual network setup, a secure storage account with diagnostics, or even a complete service deployment. You plug it into any template that needs it—without reinventing the wheel.

In this guide, we’ll break down the power of modules and walk through how to use them effectively, including:

  • What Bicep modules are and how they differ from regular Bicep files
  • How to design and reference modules in your templates
  • Best practices for creating clean, focused, and reusable modules
  • Real-world examples that demonstrate how modules fit into larger architecture

By the end, you’ll not only know how to use modules—you’ll understand why they matter, how they align with solid design principles, and how they can transform the way you structure and scale your infrastructure.

What Is a Module, Really?

At first glance, a Bicep module might seem like a new or separate concept—but technically, it’s nothing special.

A Bicep module is just another Bicep file. Same syntax. Same structure. No new language features inside the file itself.

What makes it a module isn’t the file—it’s how it’s used.

When you reference one Bicep file from another—typically using the module keyword, passing parameters into it, and optionally collecting outputs—you’re using that file as a module.

This small shift has a big impact.

Instead of writing massive, monolithic templates, you start thinking in terms of reusable building blocks—each focused on a single purpose. Sometimes that building block is small, like a storage account or virtual network. Other times it encapsulates a full application stack: a web app, database, networking, and monitoring—all packaged together.

And your main.bicep file? That becomes something bigger.

It becomes the high-level plan that brings together modular building blocks—each one encapsulating a specific part of your infrastructure. Instead of repeating details, you assemble reusable components into a coherent whole.

Here’s what this interaction looks like:

A visual representation of Azure Bicep module usage

In this diagram:

  • The main.bicep file is the calling template
  • It references a storageAccount.bicep module
  • It passes in parameters like the name, location, and SKU
  • The module defines the resource using those inputs
  • It optionally returns an output—such as the resource ID—back to the main template

💡 A file only becomes a module when it is referenced from another template using the module keyword. Copying and pasting the same resource definition into different files is not modularisation—it’s duplication.

This is how we shift from “just writing templates” to designing infrastructure as clean, reusable, maintainable components.

And the beauty is: there’s no special folder structure, no naming convention, and no added complexity. A module is simply a Bicep file that’s written with encapsulation and reuse in mind—and then used from another file that calls it.

In the next section, we’ll explore why this model is so valuable—and how to decide when it’s the right time to introduce modules into your templates.

When and Why to Use Modules

By now, you know what a Bicep module is. But just because you can write a module doesn’t mean you always should.

The real value of modules shows up when your infrastructure starts to grow—across teams, environments, or projects—and the design pressure increases. You’re not just deploying a few resources anymore. You’re building a system.

This is the moment you stop thinking in individual resources and start thinking in infrastructure patterns.

So—when should you reach for a module?

There’s no strict rule, but there are some clear signs that modularisation is the right move.

1. You’re Repeating the Same Resource Logic Across Templates

Maybe it’s a storage account that always needs diagnostics enabled, or a virtual network with the same subnet layout. If you’ve copied and pasted the same block of code more than once, that’s your signal.

Instead of duplicating, turn that resource into a module. Then reference it from anywhere.

2. You Want to Standardise Patterns Across Teams

Modules are a powerful way to enforce consistency. Let’s say your platform team wants every web app to be deployed with logging, monitoring, a naming convention, and app settings configured.

Rather than asking every team to copy best practices into their templates, you give them a module. One input, one output. Consistent behaviour, minimal effort.

3. You Need to Make the Same Change in Multiple Places

Without modules, updating infrastructure logic means chasing down the same resource across templates. With modules, you update it once—at the source—and every template that references it gets the improvement.

This becomes even more valuable when you version your modules and use them across environments, business units, or even repositories.

4. Your Template Is Getting Hard to Understand

If your main.bicep file has 800+ lines and is trying to define an entire environment—networking, compute, monitoring, security—it’s doing too much.

That’s not just hard to read. It’s hard to trust.

Modules let you break it down into logical units:

  • networking.bicep
  • storage.bicep
  • compute.bicep
  • diagnostics.bicep

Now your main file reads like a plan, not a wall of YAML-like noise.

5. You’re Designing for Reuse or Scale

Sometimes the goal is simply reuse—across dev, test, and prod. Other times, it’s scale: you want the same application pattern deployed across dozens of regions or customers.

Either way, modules let you define something once and apply it in multiple contexts, without rewriting or reworking the core logic.

The Shift: From Implementation to Composition

The more your infrastructure evolves, the more your mindset needs to shift—from “how do I define this resource?” to “how do I compose this system from clean, reliable components?”

That’s what modularisation enables.

💡 When writing templates starts to feel like wiring up systems,
you’re not just a Bicep user anymore—
you’re thinking like an infrastructure architect.

When Should You Use a Module?

  • When you’re repeating logic or effort across templates
  • When you want to enforce best practices across teams
  • When your template is becoming too long to manage confidently
  • When you’re designing for reuse across projects, tenants, or environments

Let’s Build a Module (Step-by-Step)

Now that we’ve seen why modules matter and when to use them, let’s walk through how to build one—step by step.

We’ll start with something small but meaningful: a storage account. This is a common building block in Azure, and a great example of how to turn a standard resource into a clean, reusable module.

You’ll see how to:

  • Define inputs and outputs in a module
  • Use the module keyword to plug it into another template
  • Pass values into the module, and retrieve values back

Let’s get started.

Step 1: Define the Module (storageAccount.bicep)

Create a new file called storageAccount.bicep. This file will contain all the logic needed to deploy a storage account—but written in a way that makes it reusable and parameterised.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
param storageAccountName string
param location string = resourceGroup().location
param sku string = 'Standard_LRS'

resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: sku
  }
  kind: 'StorageV2'
}

output storageAccountId string = storageAccount.id

Let’s break this down:

  • We define three parameters:
    • storageAccountName – the name of the storage account
    • location – defaults to the resource group’s location
    • sku – defaults to 'Standard_LRS'

These parameters allow the calling template to customise how this storage account is created.

  • The resource block provisions the actual storage account.
  • The output exposes the resourceId of the created storage account, which can then be used by the parent template.

🧠 This makes your module more than just a black box. It can pass useful values back to the caller—like IDs, hostnames, or connection strings. It’s how modules communicate.

Nothing here is new syntax. If you’ve written Bicep before, you’ve seen all of this. The difference now is intent: you’re writing this with reuse in mind—designing for input and output, instead of hardcoding everything.

Step 2: Plug the Module into Your Main Template (main.bicep)

Now, let’s create (or open) a main.bicep file. This will act as the parent template—it will call the module, pass values into it, and receive values back.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
param location string = resourceGroup().location

module storageModule './storageAccount.bicep' = {
  name: 'myStorageAccountDeployment'
  params: {
    storageAccountName: 'mystorage${uniqueString(resourceGroup().id)}'
    location: location
    sku: 'Standard_GRS'
  }
}

output storageAccountId string = storageModule.outputs.storageAccountId

Let’s walk through what’s happening:

  • module storageModule './storageAccount.bicep' = { ... }
    This is how you reference another Bicep file as a module. You give it a symbolic name (storageModule) and point to the file path.

  • name: 'myStorageAccountDeployment'
    This is a unique deployment name for this module instance. It’s used in the Azure deployment history to help track what was deployed.

  • params: { ... }
    This block passes values into the module’s parameters. Here’s what we’re doing:

    • storageAccountName is set to 'mystorage${uniqueString(resourceGroup().id)}'.
      This uses uniqueString() to avoid naming collisions—especially important since storage account names must be globally unique.
    • location and sku are passed through directly.
  • output storageAccountId = storageModule.outputs.storageAccountId
    This captures the output from the module—in this case, the storage account ID we’re expecting it to send back to us.

🧠 Think of it like calling a function:
You pass inputs to the module, it does its job, and gives you a result back.

A Quick Recap

You just built and used your first module. 🎉

✅ You defined a Bicep file with parameterised inputs and an output
✅ You deployed it from a parent file using the module keyword
✅ You passed values in and received a result back
✅ You structured your logic into a reusable, standalone unit

This isn’t just about syntax. You’re now thinking about infrastructure in terms of composable components—not just one long file with everything jammed inside.

Remember the diagram from earlier?
You’ve now brought that structure to life—turning your main.bicep file into a coordinator, and your storageAccount.bicep module into a focused building block.

But this is just the beginning.

Now that you’ve got the basics down, let’s take things further. In the next section, we’ll explore best practices for designing modules that are clean, focused, and production-ready.

Best Practices for Designing and Organising Modules

You’ve built a working module. That’s a good start. But as any experienced engineer knows, working doesn’t always mean good.

A messy module might technically deploy resources—but it’s hard to reuse, hard to trust, and painful to maintain. Over time, that mess compounds across projects and teams.

Great modules, on the other hand, are a pleasure to use. They feel like tools: focused, reliable, and predictable.

Here are 9 practical, field-tested principles to help you move from “working” to “well-designed.”

1. Keep Each Module Focused on One Thing

A good module should do one job well. It might define:

  • A virtual network
  • A storage account with diagnostic settings
  • An application stack (which can itself be broken into smaller modules)

🧠 This improves readability, testability, and reuse—and makes naming easier too.

If your module starts branching into “…and maybe it could also deploy a Key Vault,” it’s time to split it up.

2. Use Clear, Descriptive Names

Name your modules based on what they actually do:

  • virtualNetwork.bicep
  • webAppWithDatabase.bicep
  • module1.bicep, infraStuff.bicep

🔍 Descriptive names help both you and your team know what a module is at a glance—no digging required.

The same rule applies to parameters and outputs: if you need a comment to explain what something means, it’s probably time for a better name.

3. Parameterise What Should Be Flexible

Use parameters to allow variation across environments or contexts:

  • Resource names
  • SKUs and pricing tiers
  • Feature flags like enableDiagnostics

🛠 This lets you reuse the same module across dev, test, and prod—without editing the file.

But don’t overdo it. Not everything needs to be configurable. Some things are best left hardcoded.

4. Provide Defaults Where It Makes Sense

Help users by setting reasonable defaults:

1
2
param sku string = 'Standard_LRS'
param enableDiagnostics bool = true

🔧 Defaults reduce friction for callers while still allowing overrides when needed.

Consistency improves when users don’t have to decide on every little detail.

5. Expose Only What’s Needed via Outputs

Use output statements to return values like resource IDs, hostnames, or connection strings.

🎯 Treat outputs as your module’s public interface—keep it clean and minimal.

Avoid exposing internals unless they’re genuinely useful to the calling template.

6. Organise Modules in a Logical Folder Structure

As your module library grows, discoverability becomes a real issue. Group related modules together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/modules
  /networking
    virtualNetwork.bicep
    nsg.bicep
  /compute
    webApp.bicep
    virtualMachine.bicep
  /storage
    storageAccount.bicep
    blobContainer.bicep

📁 Without structure, even small module libraries become a maze—and people will stop reusing what’s hard to find.

7. Version Your Modules

If a module is shared across teams or environments, versioning is essential. It protects you from unintended breakage when making changes.

Use Git tags, semantic versioning, or folder versions like v1.0.0/.

🔐 Versioning gives you a safe upgrade path—so your modules can evolve without causing chaos.

8. Document Complex Logic

Don’t assume future-you (or your teammates) will remember why something exists.

Use comments to explain:

  • Why a parameter exists
  • What a conditional means
  • Why a particular API version or resource setting is used

✍️ Good code explains how. Great code also explains why.

9. Be Opinionated Where It Adds Value

Don’t aim for infinite flexibility. Aim for predictable, usable structure.

Encode best practices like:

  • Secure defaults
  • Consistent naming conventions
  • Enforced tags or diagnostics

🧱 A good module doesn’t just let you configure things—it guides the user toward doing it the right way.

Reuse is good. Predictability is better.

Bringing It All Together

A clean module is:

  • Easy to understand
  • Easy to use
  • Easy to change without fear

These design principles aren’t just about aesthetics—they’re about trust.
Trust in the module. Trust in the system. Trust in your ability to evolve infrastructure safely over time.

💬 Great modules don’t just scale infrastructure.
They scale confidence.

In the next section, we’ll take these principles off the whiteboard and put them into practice—walking through real-world module patterns that combine networking, storage, and compute into clean, composable systems.

Let’s build something real.

Real-World Examples and Use Cases

So far, we’ve explored how modules work, when to use them, and how to design them well. Now it’s time to see it all come together in action.

We’ll walk through three patterns you’re likely to encounter in your real work:

  1. A foundational module that’s reused across environments
  2. A full application stack composed from smaller modules
  3. A shared-module model used across teams and projects

Each example highlights how modular thinking improves clarity, reusability, and maintainability as your infrastructure grows.

Let’s start with the most common: a reusable virtual network.

Example 1: A Reusable Virtual Network Module

Let’s say you need to deploy a virtual network with two subnets: one for frontend resources, one for backend. This is a common pattern—and a perfect candidate for a reusable module.

Module: vnet.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
param vnetName string
param location string = resourceGroup().location

param vnetAddressPrefix string         // 👈 Full address range for the VNet (e.g. '10.0.0.0/16')
param subnet1Name string               // 👈 Name of the first subnet (e.g. 'Frontend')
param subnet1Prefix string             // 👈 CIDR range for subnet 1 (e.g. '10.0.1.0/24')
param subnet2Name string               // 👈 Name of the second subnet (e.g. 'Backend')
param subnet2Prefix string             // 👈 CIDR range for subnet 2 (e.g. '10.0.2.0/24')

resource vnet 'Microsoft.Network/virtualNetworks@2021-05-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        vnetAddressPrefix
      ]
    }
    subnets: [
      {
        name: subnet1Name
        properties: {
          addressPrefix: subnet1Prefix
        }
      }
      {
        name: subnet2Name
        properties: {
          addressPrefix: subnet2Prefix
        }
      }
    ]
  }
}

output vnetId string = vnet.id
output subnet1Id string = vnet.properties.subnets[0].id
output subnet2Id string = vnet.properties.subnets[1].id

Parent Template: main.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
param environment string = 'dev'

module vnetModule './vnet.bicep' = {
  name: 'vnetDeployment'
  params: {
    vnetName: 'myVNet-${environment}'
    vnetAddressPrefix: '10.0.0.0/16'
    subnet1Name: 'Frontend'
    subnet1Prefix: '10.0.1.0/24'
    subnet2Name: 'Backend'
    subnet2Prefix: '10.0.2.0/24'
  }
}

output vnetId string = vnetModule.outputs.vnetId
output frontendSubnetId string = vnetModule.outputs.subnet1Id
output backendSubnetId string = vnetModule.outputs.subnet2Id

This pattern scales easily.
Want to deploy to another environment? Change the parameter.
Want a third subnet? Extend the module—or make subnets dynamic with an array.
Either way, you’re designing with reuse and clarity—not copy-paste.

Example 2: Modularising a Full Application Stack

So far, we’ve focused on a single resource module. But what happens when your infrastructure gets more complex?

Let’s say you’re deploying an entire application stack—not just one component. This is where modular design really shines.

Project Structure

1
2
3
4
5
6
7
8
/main.bicep
/modules
  /compute
    webApp.bicep
  /data
    sqlDatabase.bicep
  /storage
    storageAccount.bicep

webApp.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
param appName string
param location string = resourceGroup().location
param appServicePlanId string

resource webApp 'Microsoft.Web/sites@2021-02-01' = {
  name: appName
  location: location
  properties: {
    serverFarmId: appServicePlanId
  }
}

output webAppHostName string = webApp.properties.defaultHostName

sqlDatabase.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
param serverName string
param databaseName string
param location string = resourceGroup().location

resource sqlServer 'Microsoft.Sql/servers@2021-05-01-preview' = {
  name: serverName
  location: location
  properties: {
    administratorLogin: 'adminuser'
    administratorLoginPassword: 'P@ssw0rd123!'
  }
}

resource sqlDatabase 'Microsoft.Sql/servers/databases@2021-05-01-preview' = {
  parent: sqlServer
  name: databaseName
  location: location
}

output connectionString string = 'Server=tcp:${sqlServer.name}.database.windows.net;Database=${databaseName};[credentials_here]'

storageAccount.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
param storageAccountName string
param location string = resourceGroup().location

resource storage 'Microsoft.Storage/storageAccounts@2021-06-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

output storageAccountName string = storage.name

main.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
param location string = resourceGroup().location

// App Service Plan
resource plan 'Microsoft.Web/serverfarms@2021-02-01' = {
  name: 'myAppPlan'
  location: location
  sku: {
    name: 'B1'
    tier: 'Basic'
  }
}

// Web App
module app './modules/compute/webApp.bicep' = {
  name: 'webApp'
  params: {
    appName: 'myWebApp'
    location: location
    appServicePlanId: plan.id
  }
}

// SQL Database
module sql './modules/data/sqlDatabase.bicep' = {
  name: 'sqlDb'
  params: {
    serverName: 'mySqlServer'
    databaseName: 'myDatabase'
    location: location
  }
}

// Storage Account
module storage './modules/storage/storageAccount.bicep' = {
  name: 'storage'
  params: {
    storageAccountName: 'mystorage${uniqueString(resourceGroup().id)}'
    location: location
  }
}

output host string = app.outputs.webAppHostName
output db string = sql.outputs.connectionString
output storage string = storage.outputs.storageAccountName

📦 Even with multiple layers, this pattern stays clean and manageable—because each module is focused and encapsulated.

Example 3: Sharing Modules Across Teams and Projects

In larger organisations, platform teams often create shared infrastructure modules—which are consumed by application teams to standardise deployments.

These shared modules live in a central module repository (GitHub, Azure DevOps, etc.) and are versioned for safe reuse.

Central Repo Structure

1
2
3
4
5
6
7
/shared-modules
  /networking
    vnet.bicep
  /security
    keyVault.bicep
  /storage
    storageAccount.bicep

Referencing Published Modules from a Registry

In a downstream project:

1
2
3
4
5
6
7
module sharedVNet 'br:myregistry.azurecr.io/shared-modules/networking/vnet:1.0.0' = {
  name: 'vnetDeployment'
  params: {
    vnetName: 'project-vnet'
    ...
  }
}

🔐 Here we’re using the br: syntax to reference a module stored in a Bicep registry—allowing for versioned reuse at scale.

You can even take advantage of Azure Verified Modules from Microsoft and the community—handcrafted, secure-by-default, and production-ready modules published for shared use.

Shared modules encode best practices: naming conventions, security policies, diagnostics, and more—all in one place.
App teams get standardised infrastructure, while platform teams retain control over patterns and enforcement.

The Takeaway

Whether you’re deploying a simple VNet or a full-stack application, modules scale with you. They help you move from:

  • Copy-pasting code → to referencing clean, purpose-built components
  • Managing resource files → to composing structured environments
  • Writing infrastructure → to designing reusable systems

This is the power of modularisation in Bicep.

You’re not just deploying resources anymore.
You’re building infrastructure that’s clear, composable, and ready to evolve.

Now it’s your turn: take what you’ve seen here and start breaking down your own infrastructure into clean, modular components. Start with a resource you use often. Turn it into a module. Test it. Then reuse it across projects. You’ll be surprised how quickly it pays off.

Conclusion: From Templates to Systems

What started as just a way to reuse code has now become something bigger—
a blueprint for how to think about infrastructure: cleaner, clearer, and built to scale.

You’ve now seen what Azure Bicep modules can do—from basic reuse to full-scale composition.
But more importantly, you’ve experienced a shift in mindset: from managing resources to designing systems.

This wasn’t just about breaking files into smaller pieces.
It was about scaling with intention, simplifying complexity, and building trust into every deployment.

Let’s recap what you’ve accomplished:

✅ You understood what a module is—and how it fits into the bigger picture
✅ You identified when to use modules—and why they matter
✅ You built your first module—from definition to deployment
✅ You explored best practices for clean, scalable design
✅ You saw real-world patterns that turn theory into usable infrastructure

This is how good infrastructure grows: not from more code, but from better structure.

What’s Next: Advanced Patterns and Automation

You’ve laid the groundwork. Now you’re ready to go deeper.

Here are a few directions to explore in the next phase of your journey:

  • Conditional deployments – Let modules decide whether to deploy certain resources based on parameters
  • Loops and arrays – Dynamically create multiple VMs, subnets, or other resources with for expressions
  • Cross-module dependencies – Connect outputs from one module directly into another
  • Bicep registries – Publish and version reusable modules across teams

📘 Many of these topics are covered in the next article in this series:
“Advanced Bicep Patterns: Conditional Logic, Loops, and Dependencies.”

🎯 Until then, take the time to modularise something real.
Not everything—just one part of your infrastructure that feels too tightly coupled or too often repeated.

That’s how it starts.

Thanks for following along—and well done.

You’re not just writing infrastructure anymore.
You’re designing it—with purpose, with clarity, and with confidence.