After 3.2.2, everything is in one file: Log Analytics and the Container Apps environment, and next to them the storage account from 3.2.1 in a file of its own. That works well for two resources. In the next modules, though, the Kladde gets a database, a registry, identities, Key Vault, Application Insights and Service Bus, and nobody likes reading a file with 600 lines in a review anymore.
In this lesson you split main.bicep into modules, take a ready-made module from Microsoft from the public registry, and set the
linter up so that it stops the build when it finds something.
Modules
Every Bicep file can be a module. Its parameters are the inputs, its outputs the outputs, everything else stays inside. This is how the storage account becomes a module:
// infra/modules/data.bicep
@description('Start of the name; the rest is computed from the resource group.')
@maxLength(16)
param namePrefix string
@description('Region of the storage account.')
param location string
@description('Tags for all resources.')
param tags object
resource storage 'Microsoft.Storage/storageAccounts@2026-04-01' = {
name: take('${namePrefix}${uniqueString(resourceGroup().id)}', 24)
location: location
tags: tags
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
allowBlobPublicAccess: false
}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2026-04-01' = {
parent: storage
name: 'default'
properties: {
deleteRetentionPolicy: {
enabled: true
days: 7
}
}
}
output storageAccountName string = storage.name
Three things are different from storage.bicep in 3.2.1:
- The module knows neither the Kladde nor the environment. It receives name, region and tags from outside. That makes it fit for
dev,testandprodalike. take('${namePrefix}${uniqueString(…)}', 24)shortens to the 24 characters a storage account may have, no matter how long the start is. Withouttake, Bicep warns that the name may get too long.- The blob service with soft delete is part of it now. What was drift in 3.2.1 is code now.
You use a module with module:
module data 'modules/data.bicep' = {
name: 'data'
scope: rg
params: {
namePrefix: 'st${project}${environmentName}'
location: location
tags: tags
}
}
output storageAccountName string = data.outputs.storageAccountName
- The path is relative to the calling file.
nameis the name of the deployment in the history. Every module becomes a nested deployment of its own; in the portal you see it in the resource group as an entry of its own.paramspasses the values. If a required parameter is missing or a type doesn’t fit, that’s an error at compile time.data.outputs.storageAccountNamereads an output of the module. As with resources, Bicep derives the order from it.
A module may also receive an existing result of another module. The Container Apps environment needs the workspace from the
module monitoring; it receives its name and fetches it with existing:
// infra/modules/container-apps.bicep
@description('Name of the Container Apps environment.')
param name string
@description('Region of the environment.')
param location string
@description('Tags for all resources.')
param tags object
@description('Name of the Log Analytics workspace from the module monitoring.')
param logAnalyticsWorkspaceName string
resource logs 'Microsoft.OperationalInsights/workspaces@2025-07-01' existing = {
name: logAnalyticsWorkspaceName
}
resource environment 'Microsoft.App/managedEnvironments@2026-01-01' = {
name: name
location: location
tags: tags
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logs.properties.customerId
sharedKey: logs.listKeys().primarySharedKey
}
}
workloadProfiles: [
{
name: 'Consumption'
workloadProfileType: 'Consumption'
}
]
}
}
output id string = environment.id
output defaultDomain string = environment.properties.defaultDomain
That way, the workspace’s key never travels through an output from one module to another. It’s fetched only in the module that needs it.