Creating a Configuration Module for your Infrastructure as Code

Nearly every Infrastructure as Code deployment will need some element of configuration data. Some of that will be unique and specific to that deployment, but there will generally be some element of configuration that you use in many different implementations. Some examples of this include:

  • Resource naming conventions
  • On-premises IP ranges for resource firewalls
  • Groups for RBAC roles

You can store this configuration data in your template or pass it as parameters, but you’re going to have to create this data for many different deployments. Instead of recreating this configuration data every time, consider building a configuration module.

What is a Configuration Module?

We’ve talked about IaC modules before and how to use them to create re-usable chunks of IaC you can share between teams in your organisation, or create a set of personal modules that make your life easier. The approach with a configuration module is the same, but the module doesn’t create any resources. If you need to make some configuration settings dynamic, your configuration module takes some input and outputs a load of configuration data.

Why use a Configuration Module?

Each time you create an IaC project, you can import your configuration module, and your configuration data is at your fingertips, ready to be used by any resource you want to create. No need to remember what that list of IP ranges is or work out how to name a resource; just call the module.

You can distribute your configuration module to the rest of your team or organisation, and now you have a standard set of configurations that all teams can use. If everyone uses the same configuration module, then everyone will comply with your standards. It also makes onboarding easier; your new developers don’t need to try and find out how they should name their resources or what group they should grant owner permissions to; they import the configuration module.

Finally, having a single source of truth for your configuration data also makes it much easier to update. If you add a new outbound IP to your on-premises network, you can update your configuration module, release a new version and then your users need to update their version and rerun their deployment. The only person who needed to know what the new IP is, was the person updating the module.

Creating a Configuration Module

Let’s take a look at how you would go about creating a configuration module. In this example, we’ll use Bicep, but the approach would be similar if you use Terraform or Pulumi. For our configuration module, we want to provide the following information:

  • A set of IP addresses that represent our on-premises resources
  • A set of object IDs for Azure AD groups we want to use for RBAC
  • Some naming conventions for Azure resources

We’ll be using outputs in Bicep to provide our configuration values. We’ll also use parameters to allow passing in values that we want to use to make our configuration more dynamic. In this case, we want our resource naming convention to include a user-supplied prefix.

param prefix string

Let’s tackle IP addresses first. There are a few things we want to do here. First, we have an array of IP ranges as strings. We store this as a variable so that we can transform it into other outputs. If we put it straight into another output, we could not do that.

var CorporateIpRanges = [
  '1.1.1.1'
  '1.2.3.4'
  '2.3.4.5'
]

Next, we translate that variable straight into an output for scenarios where we want a list of IPs.

output CorporateIpRanges array = CorporateIpRanges

Next, we want to transform this data. Storage accounts require IPs to be objects with value and actions. We transform this data into a storage-specific output.

output CorporateIpRangesStorage array =  [for ip in  CorporateIpRanges : {
  value: ip
  action: 'allow'
}]

Next, we will look at the object IDs for AD groups. We could also make this a list of strings, but ideally, we want to map names to GUIDs, so we’ll use a dictionary (or, in Bicep’s case, an object). We’ll see how to consume this later.

output roleGroups object = {
  globalAdmins : '2b75fe98-e56f-4e18-b4a2-8418869d71fb'
  readers : 'ffe30bfc-6ebe-4b70-b791-899a62661243'
  contributors : '14196619-523d-4c9b-a50f-41c0f72f496c'
}

Finally, naming conventions. This again needs to be a dictionary of resource type and then naming convention, but we also want to use the prefix value as part of the name.

output namingConventions object = {
  storage : '${prefix}stg'
  appservice : '${prefix}-web'
  function : '${prefix}-func'
  virtualMachine :'${prefix}-vm'
  kubernetes: '${prefix}-aks'
}

Our full configuration.bicep file looks like this:

param prefix string

output CorporateIpRanges array = [
  '1.1.1.1'
  '1.2.3.4'
  '2.3.4.5'
]

output roleGroups object = {
  globalAdmins : '2b75fe98-e56f-4e18-b4a2-8418869d71fb'
  readers : 'ffe30bfc-6ebe-4b70-b791-899a62661243'
  contributors : '14196619-523d-4c9b-a50f-41c0f72f496c'
}

output namingConventions object = {
  storage : '${prefix}stg'
  appservice : '${prefix}-web'
  function : '${prefix}-func'
  virtualMachine :'${prefix}-vm'
  kubernetes: '${prefix}-aks'
}

For our demo, we will reference this module file locally. However, you could upload this to a Bicep module repository so that it can be consumed easily by more than just yourself.

Consuming the Configuration Module

Consuming the module is no different than any other module. We reference the module file or repository and then use its outputs in our resource creation. Let’s look at creating a storage account using these values.

resource storageaccount 'Microsoft.Storage/storageAccounts@2021-02-01' = {
  name: 'scteststg1'
  location: 'westeurope'
  kind: 'StorageV2'
  sku: {
    name: 'Premium_LRS'
  }
  properties: {
  networkAcls: {
    defaultAction: 'Deny'
    ipRules: config.outputs.CorporateIpRangesStorage
  }
  }
}

resource roleAssignment 'Microsoft. Authorisation/roleAssignments@2020-10-01-preview' = {
  name: guid('globaladmin')
  properties: {
    roleDefinitionId: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'
    principalId: config.outputs.roleGroups.globalAdmins
    principalType: 'Group'
  }
  scope: storageaccount
}


We use the CorporateIpRangeStorage config value to populate the firewall on the storage account and the group ID for the ‘globalAdmins’ group.

You will notice we are not using the naming convention. This is a little more tricky; certain properties, resource name being one of them, need to be calculated when the template is compiled before it is run. If you try to use the config module for the resource name, it will error, saying that the value is unavailable at compile time. There is a way around this by moving the storage account into a nested template or, in the case of Bicep, a module. This will allow you to pass the config module output to the storage module and then consume this.

Our storage module file would look like this:


param name string
param ipAllowList array
param groupId string


resource storage account 'Microsoft.Storage/storageAccounts@2021-02-01' = {
  name: name
  location: 'westeurope'
  kind: 'StorageV2'
  SKU: {
    name: 'Premium_LRS'
  }
  properties: {

  networkAcls: {
    defaultAction: 'Deny'
    ipRules: ipAllowList
  }
  }
}

resource roleAssignment 'Microsoft. Authorisation/roleAssignments@2020-10-01-preview' = {
  name: guid('globaladmin')
  properties: {
    roleDefinitionId: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'
    principalId: groupId
    principalType: 'Group'
  }
  scope: storageaccount
}

Then in our main template, we will call this module and pass in the values from the config module.


module config 'config.bicep' = {
  name: 'config'
  params: {
    prefix: 'sctest'
  }

}

module storage 'storage.bicep' ={
   name: 'storage'
   params:{
    name: config.outputs.namingConventions.storage
    groupId: config.outputs.roleGroups.globalAdmins
    ipAllowList: config.outputs.CorporateIpRangesStorage

   }
}

This allows us to use the config module value for the resource name. This is a pain to implement, so you may want to decide whether you go down this route for resource names. All other properties should be able to be set from a config module.

This is a small example of what you can do with a configuration module. Hopefully, you can see how powerful this can be, especially when working with a team of IaC developers. You can make your configuration module as simple or complex as you or your company need.