TL;DR: The Bicep CLI has a new experimental command, bicep docs generate. Point it at a Bicep module and it writes a README.md next to it that describes the module's resources, parameters, types, outputs and usage examples, all read straight from your code. You can change the layout with your own templates, add details that aren't in the code, and document every module in a repository with one command. It's available now in Bicep CLI v0.47.16 and later.
bicep docs generate ./main.bicep
Module documentation has a habit of drifting away from the code it describes. Someone renames a parameter, adds an output or changes an allowed value, and the README quietly goes out of date. Teams that publish a lot of modules often end up writing their own scripts to keep the two in step.
bicep docs moves that job into the Bicep CLI. It compiles your module and uses the compiler's own understanding of it, so it picks up the details Bicep already knows: metadata, @description decorators, default values, allowed values, length limits, user-defined types, exported types, variables and functions, outputs, and the local modules it references. Run it again whenever the module changes, and the documentation will match the code.
You need Bicep CLI v0.47.16 or later. Check your version with bicep --version, and see Install Bicep tools if you need to upgrade. There's no feature flag to turn on.
Here's a small storage account module:
metadata name = 'Storage Account'
metadata description = 'Deploys a storage account with secure defaults.'
@description('Required. The name of the storage account. Must be globally unique.')
@minLength(3)
@maxLength(24)
param name string
@description('Optional. The Azure region to deploy the storage account to.')
param location string = resourceGroup().location
@description('Optional. The storage account SKU.')
@allowed([
'Standard_LRS'
'Standard_GRS'
'Standard_ZRS'
])
param skuName string = 'Standard_LRS'
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
name: name
location: location
kind: 'StorageV2'
sku: {
name: skuName
}
properties: {
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
}
}
@description('The resource ID of the storage account.')
output resourceId string = storageAccount.id
Generate its documentation:
bicep docs generate ./modules/storage-account/main.bicep
Bicep writes README.md next to main.bicep. For this module, the built-in layout includes a list of sections, the resource types, a usage example (more on those below), the parameters with the details of each one, and the outputs. When a module has them, it also covers exported types, variables and functions, and referenced modules. Here's an excerpt:
# Storage Account
Deploys a storage account with secure defaults.
## Parameters
| Name | Type | Required | Description |
| :-- | :-- | :-- | :-- |
| `location` | `string` | No | Optional. The Azure region to deploy the storage account to. |
| `name` | `string` | Yes | Required. The name of the storage account. Must be globally unique. |
| `skuName` | `string` | No | Optional. The storage account SKU. |
### `location`
- Default value: `resourceGroup().location`
### `name`
- Min length: 3
- Max length: 24
### `skuName`
- Default value: `'Standard_LRS'`
- Allowed values: `Standard_GRS`, `Standard_LRS`, `Standard_ZRS`
A couple of options you'll use often:
--stdout prints the result to the terminal instead of writing a file, which is handy for a quick preview.
--outfile writes to a file of your choice, and --outdir writes the documentation into another folder.
Because the command is experimental, Bicep prints a warning each time you run it.
Examples are often the most useful part of module documentation, so bicep docs looks for them next to your module. By default it picks up:
.bicep files in an examples folder, and main.bicep files in its subfolders
*.test.bicep files anywhere under a tests folder
Files named dependencies*.bicep are skipped. Each example's heading comes from its metadata name. Without one, Bicep uses the name of the folder the example is in, or its file name if it's not in a subfolder. The description comes from metadata description, or else the // comments at the top of the file. For example, this file in modules/storage-account/examples becomes a "Zone-redundant storage account" example in the README:
metadata name = 'Zone-redundant storage account'
metadata description = 'Deploys a storage account that keeps copies of your data in three availability zones.'
module storageAccount '../main.bicep' = {
params: {
name: 'stdocsdemo001'
skuName: 'Standard_ZRS'
}
}
If your examples live somewhere else, list their locations in documentation.examples.sources in bicepconfig.json. Your list replaces the default locations.
The built-in layout is a good start, but you'll probably have your own conventions. bicep docs renders documentation with Scriban, a lightweight text templating language. Write a template, then point to it from bicepconfig.json:
{
"documentation": {
"template": {
"file": "templates/readme.scriban"
}
}
}
The template path is relative to the bicepconfig.json file. As with its other settings, Bicep uses the bicepconfig.json nearest to each module.
This template writes a parameters table and an outputs table:
# {{ module.name }}
{{ module.description }}
## Parameters
| Name | Type | Required | Description |
| :-- | :-- | :-- | :-- |
{{~ for parameter in module.parameters ~}}
| `{{ parameter.name }}` | `{{ parameter.type }}` | {{ if parameter.required }}Yes{{ else }}No{{ end }} | {{ parameter.description }} |
{{~ end ~}}
## Outputs
| Name | Type | Description |
| :-- | :-- | :-- |
{{~ for output in module.outputs ~}}
| `{{ output.name }}` | `{{ output.type }}` | {{ output.description }} |
{{~ end ~}}
The ~ inside the braces removes the spaces and line breaks next to a tag, so the loops don't add blank lines that would break the tables. Your template receives a module object with the module's name, description, targetScope, resourceTypes, parameters, exportedTypes, exportedVariables, exportedFunctions, outputs, references and usageExamples. The command documentation lists every field.
A couple of tips:
- Split a big template into smaller files with Scriban's
include, and set documentation.template.includeRoot to the folder that holds them.
- The built-in Markdown template is a Scriban template too, so it's a good place to start.
Some information doesn't belong in Bicep, such as the team that owns a module, where to get support, or the module's version. Pass it in as custom values and use them in your template as custom.<key>:
**Owner:** {{ custom.owner }} | **Version:** {{ custom.version }} | [Get support]({{ custom.supportUrl }})
Supply values one at a time with --custom-template-value, or load several from a JSON file with --custom-template-value-file-path:
bicep docs generate ./modules/storage-account/main.bicep `
--custom-template-value owner="Platform Team" `
--custom-template-value-file-path ./docs-values.json
Where docs-values.json contains:
{
"supportUrl": "https://contoso.example/support",
"version": "1.0.0"
}
Both options can be repeated. If the same key is set more than once, the last one on the command line wins.
Scriban writes whatever text your template contains, so the output doesn't have to be Markdown. A template could produce JSON for a module catalogue, HTML for an internal portal, or input for another tool. This template produces JSON, and Scriban's object.to_json function takes care of quotes and escaping:
{
"name": {{ module.name | object.to_json }},
"description": {{ module.description | object.to_json }},
"parameters": [
{{~ for parameter in module.parameters ~}}
{
"name": {{ parameter.name | object.to_json }},
"type": {{ parameter.type | object.to_json }},
"required": {{ parameter.required | object.to_json }}
}{{ if !for.last }},{{ end }}
{{~ end ~}}
]
}
Set documentation.template.file to this template, then use --outfile to save the result with a .json file name:
bicep docs generate ./modules/storage-account/main.bicep --outfile ./modules/storage-account/module.json
To change the default file name for every module instead, set documentation.output.file in bicepconfig.json.
Each module uses the one template set in its nearest bicepconfig.json. To get both Markdown and JSON from the same modules, make that template a small switch that includes the right template based on a custom value:
{{~ if custom.format == "json" ~}}
{{~ include "catalog.scriban" ~}}
{{~ else ~}}
{{~ include "readme.scriban" ~}}
{{~ end ~}}
Set documentation.template.includeRoot to the folder that holds the templates. Then run the command as normal for the README, and add --custom-template-value format=json --outfile ./modules/storage-account/module.json for the JSON.
Use --pattern to document every module that matches a wildcard:
bicep docs generate --pattern './modules/**/main.bicep'
Each module gets its own README.md next to its main.bicep. Add --outdir ./docs to put them in a separate folder instead, and Bicep recreates the folder structure beneath it. If a module has compile errors, Bicep reports them, carries on documenting the other modules, and returns exit code 1 at the end.
To see this at scale, the demo repository generates Azure Verified Modules (AVM) style documentation for 20 App Service modules, including their child modules, with a single command. Its template works out each module's registry reference and main resource type from the module itself. The AVM team is standardising and consolidating its tooling, and bicep docs is part of that work.
Because the documentation comes from the code, your pipeline can check that it's current. Regenerate it, then fail the build if anything changed:
bicep docs generate --pattern './modules/**/main.bicep'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$changes = git status --porcelain
if ($changes) {
$changes
throw 'Module documentation is out of date. Run bicep docs generate and commit the result.'
}
The generated documentation is only as good as your descriptions, so the new description linter rules in Bicep v0.47 make a good partner. They're off by default and flag missing or empty @description decorators. Turn on the ones you need in bicepconfig.json:
{
"analyzers": {
"core": {
"rules": {
"use-description-params": { "level": "warning" },
"use-description-outputs": { "level": "warning" },
"use-description-types": { "level": "warning" },
"use-description-type-properties": { "level": "warning" }
}
}
}
}
There's also use-description-vars for variables. Thanks to @johnlokerse for contributing these rules!
The same generator is available through the Bicep CLI's JSON-RPC interface as bicep/generateDocs. It returns the rendered text and leaves writing files to you. For .NET, the Azure.Bicep.RpcClient package wraps it.
bicep docs is experimental. The command options, configuration, template model and built-in layout may change between releases without a breaking-change notice.
One change is already on the way. In v0.47 you can also choose a template with the --template-file and --template-root options. The next release removes these options, so templates are set only with documentation.template.file and documentation.template.includeRoot in bicepconfig.json. The examples in this post already use bicepconfig.json, so they'll keep working.
Try it on your own modules and tell us what works and what's missing by opening an issue in the Bicep repository.