A first look at Azure Bicep's new documentation generator

Native README generation is finally coming to the Bicep CLI

A first look at Azure Bicep's new documentation generator

Every Bicep module author faces the same chore as anyone else:

Keeping a README file in sync with all parameters, outputs, and types the compiler already knows everything about.

I've been there myself. When bicep local deploy released, I published bicep-local-docgen to take some of the heavy lifting away from Bicep's own compiler.

But there's finally some light at the end of the tunnel. With the following pull request by one of the members of the AVM project, it seems that the Bicep CLI gets its own command: bicep docs generate.

In this post, I'll walk you through the five-year history behind that command, the tooling the community built to fill the gap, and why the AVM project finally pushed documentation generation into the Bicep CLI itself. Then, we go to the actual command itself.

Five years of asking

The idea to get native support for documentation generation is not new. Back in October 2021, Ben Wilkinson opened 4395, "Auto generated documentation e.g. Markdown and schema", asking for exactly this: a built-in way to turn a Bicep module into readable documentation. Nearly five years later, that issue was still open, parked on the team's backlog while thumbs-ups and workarounds accumulated underneath it.

The thread also captured why it stayed open so long. Bicep maintainer Anthony Martin observed early on that the hard part isn't producing Markdown; it's that everyone wants their documentation in some different way. A built-in generator would either be too opinionated to be useful, or collapse under a pile of formatting options.

But it was Jared Holgate, one of the core maintainers of AVM, who broke the deadlock. Jared opened a proposal, which led to a design document: REP 0025, "Module documentation generation commands". And as you'll soon learn, it answers a lot of the formatting objections.

What the community built in the meantime

While the issue sat open, the community didn't sit still either. It built its own tooling, so it's good to look back at the tools that fall roughly into three generations.

The first generation worked around the problem by not parsing Bicep at all. This was PSDocs.Azure. PSDocs.Azure consumed ARM template JSON, so the recipe was straightforward in most blog posts: run bicep build first, then hand the compiled output to PSDocs. It worked, but anything that didn't survive compilation was lost along the way, and native Bicep support, requested back in 2021, never arrived.

[!NOTE]
The latest stable release dates from May 2021.

A second tool targeted Bicep more directly and was developed by Christos Galanopoulos. It was fully written as Go CLI and was simply named bicep-docs. It's still an active project maintained today and takes more of a hybrid approach: it compiles your file to ARM JSON, but additionally scans the Bicep source for the things compilation obscures. Think about declarations and decorators.

The PSBicep module went one step further and embedded the New-BicepMarkdownDocumentation cmdlet to the actual Bicep .NET assembly. This way, it parses your file with the real compiler (and even in-process as well).

My own bicep-local-docgen sits in a different lane and doesn't document Bicep modules at all. Instead, it documents Bicep Local Deploy extensions by reading the C# resource models an extension author writes and generating Markdown reference documentation from them. Just complementary rather than competing. But it taught me the same lesson every tool in this list learned the hard way: documentation tooling really wants to live next to the thing it documents.

[!NOTE]
There are other abandoned attempts too. Joachim Dalen created a C# generator built on Bicep's own libraries, but was archived early in 2025.

AVM: the straw that broke the camel's back

If there's any Bicep project out there that knows the pain at such a scale, it's the Azure Verified Modules (AVM) project. The AVM specification even mandates it in a specification document to produce proper documentation. It specifies that every module produces a README.md file.

To make it easier for authors, the AVM created its own tooling, and oh boy, this PowerShell script has grown to become a beast, which is roughly 2400 lines. That tool is known as Set-ModuleReadMe.ps1. It parses the ARM JSON, and rebuilds the README section by section. It even calls out to Microsoft Learn at generation time to validate reference links, which means generating documentation requires an internet connection.

The script works. I think it documents more than 570 modules in the public registry today. But it's held together by conventions. Parameters land in the Required or Optional based on the prefix of their description text. Hand-written content only survives in a handful of locations. And every time the Bicep language grows a new feature, someone has to teach the script about it from the outside.

The design proposal states the motivation bluntly: the AVM script is "non-trivial PowerShell that has to be maintained outside of the Bicep compiler, despite depending on Bicep's compiled output."

And here's the kicker: the pull request's author, Jared Holgate, is a core team member of the AVM project, and REP 0025 explicitly names Set-ModuleReadme.ps1 as the prior art the new feature is "intended to replace for most consumers."

This isn't the Bicep team guessing what a docs generation should look like. It comes directly from the team with the largest generated-docs estate in the ecosystem, moving that into the compiler itself.

Meet bicep docs

The pull request is merged into main and introduces something the Bicep CLI has never had before: a command group.

Instead of a flat bicep generate-docs, you get a docs verb with two subcommands underneath. It's a deliberate choice in the design document because it leaves room for future additions like docs validate or docs-init-template.

# Generate a README.md next to the module
bicep docs generate ./main.bicep

# Bulk generation across a module tree
bicep docs generate --pattern './modules/**/main.bicep'

# Render to stdout without writing anything
bicep docs output ./main.bicep

docs generate writes a README.md next to your module (the file name is configurable with --output-file), and the --pattern variant follows the same glob convention that build, format, and lint already use for bulk operations. docs output renders the same documentation to stdout without touching disk.

So what ends up in the generated README file? Rather than describing the output here, let me show it. I wrote a small storage account module that exercises the features I care about, like file-level metadata, decorated parameters, and a discriminated union type:

metadata name = 'Storage Account'
metadata description = 'Deploys an Azure Storage Account with optional blob containers.'

@description('The name of the storage account. Must be globally unique.')
@minLength(3)
@maxLength(24)
param name string

@description('The storage account SKU.')
@allowed(['Standard_LRS', 'Standard_GRS', 'Premium_LRS'])
param skuName string = 'Standard_LRS'

@description('Network access configuration for the storage account.')
param networkAccess networkAccessType = { kind: 'public' }

@description('Defines how the storage account can be reached.')
@discriminator('kind')
type networkAccessType = publicAccess | restrictedAccess

@description('Publicly accessible from all networks.')
type publicAccess = {
  kind: 'public'
}

@description('Restricted to a set of allowed CIDR ranges.')
type restrictedAccess = {
  kind: 'restricted'

  @description('The CIDR ranges allowed to access the storage account.')
  allowedCidrs: string[]
}

// ...plus the storage resources, a location and containers parameter, and two outputs

Next to it sits an examples/basic/main.bicep showing how to consume the module (sounds familiar, right?). Running the bicep docs generate main.bicep and the generated README opens with the title and description from the metadata statements, a navigation block, a table of the resource types, the module deploys, and my example file rendered as a numbered, ready-to-copy usage example (discovered by the examples/ folder).

And here's the part where all the decorators you've been adding finally pay off:

## Parameters

| Name | Type | Required | Description |
| :-- | :-- | :-- | :-- |
| `containers` | `array` | No | Blob containers to create in the storage account. |
| `location` | `string` | No | The Azure region to deploy to. |
| `name` | `string` | Yes | The name of the storage account. Must be globally unique. |
| `networkAccess` | `object` | No | Network access configuration for the storage account. |
| `skuName` | `string` | No | The storage account SKU. |

### `networkAccess`

- Default value: `{ kind: 'public' }`

- Discriminator: `kind`
  - `public`:
    - `kind` (`string`), required
      - Allowed values: `public`
  - `restricted`:
    - `allowedCidrs` (`array`), required: The CIDR ranges allowed to access the storage account.
    - `kind` (`string`), required
      - Allowed values: `restricted`

Every parameter gets a detail section with its default value, allowed values, and length or value constraints (name shows its min and max length; secure parameters render as securestring). But the networkAccess breakdown is my favorite part: the discriminated union is expanded recursively, case by case, with required flags and descriptions. You don't have to guess what shape it has through the source itself.

Stepping a bit back. Remember Anthony Martin's objection that everyone wants their documentation formatted differently? The answer turns out to be templates. The built-in output is a sensible default, but pass --template-file with your own Scriban template, and you can reshape the entire document. And if you want to inject values from the outside, use --custom-template-value.

The command is going to be shipped as an experimental feature. But don't worry for this one, you don't have to turn on any flag in your bicepconfig.json. The full command and template references lives in docs/experimental/docs-commands.md.

What this means

The real differentiator of every tool from the middle of the post comes down to one thing: architectural.

bicep docs reads the compiler's semantic model, not the compiled JSON. Everything the community had to do is reverse-engineer from the outside. Now, every user-defined type, decorator, and exported function just flows into the documentation for free as the language (Bicep) evolves. A module with compile errors is refused outright rather than half-documented. And the output is deterministic: the same module with the same template produces the same README, byte for byte.

That claim on determinism isn't theoretical. If you look at the original PR, Jarod ran the tool across the entire public module registry. Those were 573 AVM module entry points in about seven and a half minutes, with all the READMEs matching the checked-in ones (at least, it was 572, as there was a single failure a module with compile errors).

So where does that leave you? If you author modules, the wrapper scripts, or the other tooling that you've used can now have a retirement date. If you contribute to AVM, so does the 2400-line PowerShell script. And lastly, this one is even better. The engine is also exposed over JSON-RPC and through the Azure.Bicep.RpcClient .NET package, so a pipeline can document hundreds of modules in a single process instead of shelling out per file.

One honest caveat before you wire anything into a production pipeline. Even though the pull request has been merged to main, it's still labeled as experimental. It's now your time to give feedback!