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:

bicep
// 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, test and prod alike.
  • take('${namePrefix}${uniqueString(…)}', 24) shortens to the 24 characters a storage account may have, no matter how long the start is. Without take, 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:

bicep
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.
  • name is 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.
  • params passes the values. If a required parameter is missing or a type doesn’t fit, that’s an error at compile time.
  • data.outputs.storageAccountName reads 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:

bicep
// 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.