One Bicep File Has Become Too Large? Create Modules

Published on:

main.bicep now contains inputs, naming logic, a storage account, Blob settings, a resource loop, a conditional resource, and outputs. Adding networking, monitoring, or another service to the same file would make reviews and maintenance increasingly difficult.

Keep main.bicep as the deployment entry point and move the complete storage implementation into a focused module. The refactor must not recreate or reconfigure the existing TEST resources.

Create the Storage Module

Create modules/storage.bicep:

param applicationName string
param containerNames array
param costCenter string
param deployAuditContainer bool
param environment string
param location string
param owner string
param storageSku string

var normalizedEnvironment = toLower(environment)
var storageAccountName = 'stct${normalizedEnvironment}${uniqueString(resourceGroup().id)}'
var commonTags = {
  Application: applicationName
  CostCenter: costCenter
  Environment: toUpper(normalizedEnvironment)
  ManagedBy: 'Bicep'
  Owner: owner
}

resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
  name: storageAccountName
  location: location
  tags: commonTags
  sku: {
    name: storageSku
  }
  kind: 'StorageV2'
  properties: {
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
    supportsHttpsTrafficOnly: true
  }
}

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-06-01' = {
  parent: storageAccount
  name: 'default'
  properties: {
    containerDeleteRetentionPolicy: {
      enabled: true
      days: 7
    }
    deleteRetentionPolicy: {
      enabled: true
      days: 7
    }
  }
}

resource appContainers 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = [
  for containerName in containerNames: {
    parent: blobService
    name: containerName
    properties: {
      publicAccess: 'None'
    }
  }
]

resource auditContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = if (deployAuditContainer) {
  parent: blobService
  name: 'audit'
  properties: {
    publicAccess: 'None'
  }
}

output storageAccountName string = storageAccount.name
output storageAccountId string = storageAccount.id
output blobEndpoint string = storageAccount.properties.primaryEndpoints.blob
output containerIds array = [
  for i in range(0, length(containerNames)): appContainers[i].id
]
output auditContainerId string? = deployAuditContainer ? auditContainer.id : null

The module has a clear contract:

  • parameters are the values it needs from the calling file
  • resources are the storage implementation it owns
  • outputs are the values it returns to the calling file

Turn main.bicep into the Orchestrator

Keep the existing parameter declarations in main.bicep. Remove the storage variables, resources, and direct resource outputs, then add:

module storage './modules/storage.bicep' = {
  name: '${deployment().name}-storage'
  params: {
    applicationName: applicationName
    containerNames: containerNames
    costCenter: costCenter
    deployAuditContainer: deployAuditContainer
    environment: environment
    location: location
    owner: owner
    storageSku: storageSku
  }
}

output storageAccountName string = storage.outputs.storageAccountName
output storageAccountId string = storage.outputs.storageAccountId
output blobEndpoint string = storage.outputs.blobEndpoint
output containerIds array = storage.outputs.containerIds
output auditContainerId string? = storage.outputs.?auditContainerId

main.bicep passes its parameter values through params. It reads returned values through storage.outputs.

The module deployment name combines the parent deployment name with -storage. This avoids one fixed nested-deployment name when different parent deployments run at the same scope.

The DEV, TEST, and PROD .bicepparam files remain unchanged. They still link to main.bicep, whose input contract has not changed.

Validate the Refactor

Lint both files:

az bicep lint \
  --file modules/storage.bicep

az bicep lint \
  --file main.bicep

Then compile the TEST parameter file with the refactored entry point:

az bicep build-params \
  --file environments/test.bicepparam \
  --stdout > /dev/null

Continue only when all commands complete without diagnostics.

Prove That the Resources Will Not Be Recreated

Select the CloudTrips TEST subscription and run what-if:

az account set \
  --subscription "<CloudTrips TEST subscription name or ID>"

az deployment group what-if \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --parameters environments/test.bicepparam

The preview can show a nested Microsoft.Resources/deployments resource for the storage module. It can also report Modify for the Blob service and its containers because Azure adds default properties that the Bicep file does not declare. In this deployment, the expected lines are:

  • allowPermanentDelete: false and staticWebsite.enabled: false on the Blob service
  • defaultEncryptionScope: "$account-encryption-key" and denyEncryptionScopeOverride: false on each container

The minus sign means that a value exists in Azure’s current resource representation but is absent from the proposed template. Azure restores these provider defaults after deployment, so Microsoft classifies such entries as what-if noise.

Confirm that the storage account reports NoChange and that the preview contains no Create, Delete, or replacement of the storage account, Blob service, or containers. Their names and resource IDs must remain the same; only the Bicep code organization changed.

TEST what-if result after the module refactor showing provider-default noise without resource replacement

Deploy the Module Refactor

Deploy the same TEST configuration:

az deployment group create \
  --name deploy-cloudtrips-storage-test-modules \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --parameters environments/test.bicepparam \
  --query "properties.{state:provisioningState,outputs:outputs}" \
  --output yaml

This creates two deployment records: --name sets the parent name, and the module expression appends -storage for the nested deployment. The storage resources are deployed only once, through that module.

Confirm that the state is Succeeded and that the entry-point outputs still contain the storage account and container values.

Verify the Nested Module Deployment

A local Bicep module is compiled into a nested ARM deployment. Verify the module deployment:

az deployment group show \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --name deploy-cloudtrips-storage-test-modules-storage \
  --query "properties.{state:provisioningState,outputs:outputs}" \
  --output yaml

Confirm that the nested state is Succeeded. In the Azure portal, the resource group’s Deployments page should show both the parent deployment and its storage-module deployment.

Resource-group Deployments page showing the successful parent and storage-module deployments

CloudTrips now has a small entry point and a storage module with a defined input/output contract. Future services can be added as their own modules without returning all implementation details to main.bicep.