How to easily developer your first Bicep Local Deploy extension with Bicep.LocalDeploy.Templates
Learn to develop your first Bicep extension using templates

Bicep v0.37.4 simplified the Local Deploy authoring experience. With this release, developers started building extensions, including myself.
Fast-forwarding a month, other community members started to create lists. Each extension in the list followed the same pattern:
- Have a
src
directory storing your application extension code. - Create a base class.
- Create handlers and models.
- Add documentation attributes.
- Add a build automation script to publish the extension locally.
I noticed the pattern. Every new extension needed the same scaffolding. People were copying files from these extension repositories and stripping away the domain logic.
That's why I created Bicep.LocalDeploy.Templates
. It captured this structure and best practices. You install it, run one command, and get a working project with everything in the right place. In this blog post, I'll guide you through getting started with these templates.
Prerequisites
To follow along with this guide, you need:
- .NET SDK 9.0: Download from dotnet.microsoft.com or install via WinGet:
winget install Microsoft.DotNet.SDK.9
- Bicep CLI v0.37.4 or higher: Install or upgrade:
winget install Microsoft.Bicep
- PowerShell 7+: For running the build script
- Code editor: Visual Studio Code with the Bicep extension (recommended)
Got everything installed? Let's install the templates.
Installing the templates
Before templates, you had two options for starting a Bicep Local Deploy project:
- Clone an existing example repository and strip out the domain logic.
- Create everything from scratch following the documentation.
Both options waste time. Cloning means deleting code you don't need. Starting from scratch means setting up build scripts, project files, and folder structures by hand.
The Bicep.LocalDeploy.Templates
package fixes this. You get a clean slate with the right structure. Just scaffold it and start building your extension from the sample resources already there. To leverage these templates, you first need to install the package:
dotnet new install Bicep.LocalDeploy.Templates
This downloads the templates to your machine. To list out the available templates, you can check what's available with the following command:
dotnet new list --tag Bicep
Output:
Template Name Short Name Language Tags
------------------------------ -------------- -------- ------------
Bicep LocalDeploy Extension bicep-ld-tpl [C#] Bicep/Web
The bicep-ld-tpl
template is your starting point.
Creating your first extension
Let's build a simple extension. We'll start by creating the project structure using the bicep-ld-tpl
template.
Step 1 – Generate the project
dotnet new bicep-ld-tpl -n ConfigManager
This creates a new directory called ConfigManager
. Navigate to it:
cd ConfigManager
Step 2 – Understand the structure
The template gives you the following files:
ConfigManager/
├── build.ps1 # Build and publish script
├── global.json # .NET SDK version lock
├── .gitignore # Git ignore rules
├── .biceplocalgenignore # Documentation generator ignore
├── README.md # Project documentation
└── src/
├── ConfigManager.csproj # Project file
├── Program.cs # Application entry point
├── GlobalUsings.cs # Global using directives
├── Models/
│ ├── Configuration.cs # Extension configuration
│ └── SampleResource/
│ └── SampleResource.cs # Example resource model
└── Handlers/
├── ResourceHandlerBase.cs # Base handler with helpers
└── SampleHandler/
└── SampleResourceHandler.cs
Every file has a purpose and nothing extra. The SampleResource.cs
and SampleResourceHandler.cs
files illustrate an example and don't actually work.
Step 3 – Examine program.cs
Open src/program.cs
in your editor:
using Bicep.Local.Extension.Host.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ConfigManager.Handlers.SampleHandler;
using ConfigManager.Models;
var builder = WebApplication.CreateBuilder();
builder.AddBicepExtensionHost(args);
builder
.Services.AddBicepExtension(
name: "ConfigManager",
version: "0.0.1",
isSingleton: true,
typeAssembly: typeof(Program).Assembly,
configurationType: typeof(Configuration)
)
.WithResourceHandler<SampleResourceHandler>();
var app = builder.Build();
app.MapBicepExtension();
await app.RunAsync();
This is your extension's entry point. The template is already configured with your extension name (namespace). Key parts:
AddBicepExtensionHost
: Registers core extension infrastructure.AddBicepExtension
: Registers your extension with metadata.WithResourceHandler
: Registers resource handlers.MapBicepExtension
: Maps HTTP endpoints.
Step 4 – Look at the sample handler
Open src/Handlers/SampleHandler/SampleResourceHandler.cs
:
using Microsoft.Extensions.Logging;
using ConfigManager.Models;
using ConfigManager.Models.SampleResource;
namespace ConfigManager.Handlers.SampleHandler;
public class SampleResourceHandler : ResourceHandlerBase<SampleResource, SampleResourceIdentifiers>
{
private const string ResourcesApiEndpoint = "api/v1/resources";
public SampleResourceHandler(ILogger<SampleResourceHandler> logger)
: base(logger) { }
protected override async Task<ResourceResponse> Preview(
ResourceRequest request,
CancellationToken cancellationToken
)
{
var existing = await GetResourceAsync(
request.Config,
request.Properties,
cancellationToken
);
if (existing is not null)
{
// Populate output properties from existing resource
request.Properties.ResourceId = existing.ResourceId;
request.Properties.CreatedAt = existing.CreatedAt;
request.Properties.UpdatedAt = existing.UpdatedAt;
}
return GetResponse(request);
}
protected override async Task<ResourceResponse> CreateOrUpdate(
ResourceRequest request,
CancellationToken cancellationToken
)
{
var props = request.Properties;
_logger.LogInformation("Ensuring sample resource: {Name}", props.Name);
var existing = await GetResourceAsync(request.Config, props, cancellationToken);
if (existing is null)
{
_logger.LogInformation("Creating new sample resource: {Name}", props.Name);
await CreateResourceAsync(request.Config, props, cancellationToken);
existing =
await GetResourceAsync(request.Config, props, cancellationToken)
?? throw new InvalidOperationException(
"Resource creation did not return resource."
);
}
else
{
_logger.LogInformation(
"Updating existing sample resource: {ResourceId}",
existing.ResourceId
);
await UpdateResourceAsync(request.Config, props, existing, cancellationToken);
existing =
await GetResourceAsync(request.Config, props, cancellationToken)
?? throw new InvalidOperationException("Resource update did not return resource.");
}
// Populate output properties
props.ResourceId = existing.ResourceId;
props.CreatedAt = existing.CreatedAt;
props.UpdatedAt = existing.UpdatedAt;
return GetResponse(request);
}
protected override SampleResourceIdentifiers GetIdentifiers(SampleResource properties) =>
new() { Name = properties.Name };
private async Task<SampleResource?> GetResourceAsync(
Configuration configuration,
SampleResource props,
CancellationToken ct
)
{
try
{
var response = await CallApiForResponse<SampleResource>(
configuration,
HttpMethod.Get,
$"{ResourcesApiEndpoint}/{Uri.EscapeDataString(props.Name)}",
ct
);
return response;
}
catch
{
// Resource not found
return null;
}
}
private async Task CreateResourceAsync(
Configuration configuration,
SampleResource props,
CancellationToken ct
)
{
var createPayload = new
{
name = props.Name,
description = props.Description,
isEnabled = props.IsEnabled,
status = props.Status?.ToString(),
maxRetries = props.MaxRetries,
timeoutSeconds = props.TimeoutSeconds,
metadata = props.Metadata,
};
var response = await CallApiForResponse<SampleResource>(
configuration,
HttpMethod.Post,
ResourcesApiEndpoint,
ct,
createPayload
);
if (response == null)
{
throw new InvalidOperationException(
$"Failed to create sample resource '{props.Name}'."
);
}
}
private async Task UpdateResourceAsync(
Configuration configuration,
SampleResource props,
SampleResource existing,
CancellationToken ct
)
{
var updatePayload = new
{
description = props.Description,
isEnabled = props.IsEnabled,
status = props.Status?.ToString(),
maxRetries = props.MaxRetries,
timeoutSeconds = props.TimeoutSeconds,
metadata = props.Metadata,
};
var response = await CallApiForResponse<SampleResource>(
configuration,
HttpMethod.Put,
$"{ResourcesApiEndpoint}/{Uri.EscapeDataString(existing.Name)}",
ct,
updatePayload
);
if (response == null)
{
throw new InvalidOperationException(
$"Failed to update sample resource '{props.Name}'."
);
}
}
}
This handler implements two operations:
CreateOrUpdate
: Create or update a resource.Preview
: Checks if the resource exists.
Step 5 – Build the extension
With the scaffolding of dotnet new
, a build script is added at the root of the project. Modify the global.json
version number with your actual .NET SDK version and run the build script:
.\build.ps1
This does several things:
- Restores NuGet packages.
- Builds the project by default in
Release
mode. - Publish extensions for cross-platform.
- Creates the actual extension using
bicep publish-extension
locally.
The output appears in the output
directory.
Step 6 – Test it
To test the extension, you can create a bicepconfig.json
:
{
"experimentalFeaturesEnabled": {
"localDeploy": true,
"extensibility": true
},
"extensions": {
"myExtension": "output/my-extension"
},
"implicitExtensions": []
}
Then, create a main.bicep
file with a main.bicepparam
file:
// main.bicep
targetScope = 'local'
extension myExtension with {
baseUrl: 'https://my-extension-url'
}
resource SampleResource 'SampleResource' = {
name: 'sampleResource'
}
// main.bicepparam
using 'main.bicep'
Deploy it:
bicep local-deploy main.bicepparam
Obviously, the deployment will fail, as it hasn't actually implemented a real REST API call. Let's customize the extension.
Customizing your extension
The template gives you a starting point. Now you can customize it with an actual implementation. We'll be using the famous JSONPlaceholder API endpoints for testing.
Step 1 – Modifying the model
In the src/Models/SampleResource/SampleResource.cs
, modify the code with:
using System.Text.Json.Serialization;
using Bicep.Local.Extension.Types.Attributes;
namespace ConfigManager.Models.SampleResource;
[BicepFrontMatter("category", "JSONPlaceholder")]
[BicepDocHeading(
"Post",
"Represents a post in the JSONPlaceholder API."
)]
[BicepDocExample(
"Creating a post",
"This example shows how to create a post with all required properties.",
@"resource myPost 'Post' = {
title: 'My First Post'
body: 'This is the content of my first post.'
userId: 1
}
"
)]
[BicepDocExample(
"Creating another post",
"This example demonstrates creating a post for a different user.",
@"resource userPost 'Post' = {
title: 'User Post Example'
body: 'This post belongs to user 5 and contains useful information.'
userId: 5
}
"
)]
[BicepDocCustom(
"Notes",
@"When working with the `Post` resource, ensure you have the extension imported in your Bicep file:
```bicep
// main.bicep
targetScope = 'local'
param baseUrl string
extension myExtension with {
baseUrl: baseUrl
}
resource myPost 'Post' = {
title: 'Sample Post'
body: 'This is a sample post'
userId: 1
}
// main.bicepparam
using 'main.bicep'
param baseUrl = 'https://jsonplaceholder.typicode.com'
```
All properties (title, body, userId) are required for creating a post."
)]
[BicepDocCustom(
"Additional reference",
@"For more information, see the following links:
- [JSONPlaceholder API Documentation][00]
<!-- Link reference definitions -->
[00]: https://jsonplaceholder.typicode.com/"
)]
[ResourceType("Post")]
public class Post : PostIdentifiers
{
// Required writable properties
[TypeProperty("The body content of the post.", ObjectTypePropertyFlags.Required)]
public string Body { get; set; } = string.Empty;
[TypeProperty("The ID of the user who created the post.", ObjectTypePropertyFlags.Required)]
public int UserId { get; set; }
// Read-only output property - the ID assigned by the API
[TypeProperty(
"The unique identifier assigned to the post by the API.",
ObjectTypePropertyFlags.ReadOnly
)]
public int Id { get; set; }
}
public class PostIdentifiers
{
// Using title as the identifier for the resource
[TypeProperty(
"The title of the post, used as the unique identifier.",
ObjectTypePropertyFlags.Identifier | ObjectTypePropertyFlags.Required
)]
public string Title { get; set; } = string.Empty;
}
Step 2 – Change the handler
Open the ResourceSampleHandler.cs
and modify the code with:
using Microsoft.Extensions.Logging;
using ConfigManager.Models;
using ConfigManager.Models.SampleResource;
namespace ConfigManager.Handlers.SampleHandler;
/// <summary>
/// Handler for Post operations using the JSONPlaceholder API.
/// Implements Preview and CreateOrUpdate operations.
/// </summary>
public class PostHandler : ResourceHandlerBase<Post, PostIdentifiers>
{
private const string PostsApiEndpoint = "posts";
public PostHandler(ILogger<PostHandler> logger)
: base(logger) { }
protected override async Task<ResourceResponse> Preview(
ResourceRequest request,
CancellationToken cancellationToken
)
{
// JSONPlaceholder doesn't support querying by title, so we can't check for existing posts
// We'll always create a new post in CreateOrUpdate
await Task.CompletedTask;
return GetResponse(request);
}
protected override async Task<ResourceResponse> CreateOrUpdate(
ResourceRequest request,
CancellationToken cancellationToken
)
{
var props = request.Properties;
_logger.LogInformation("Creating post with title: {Title}", props.Title);
// Validate that all required properties are set
if (string.IsNullOrWhiteSpace(props.Title))
{
throw new InvalidOperationException("Title is required.");
}
if (string.IsNullOrWhiteSpace(props.Body))
{
throw new InvalidOperationException("Body is required.");
}
if (props.UserId <= 0)
{
throw new InvalidOperationException("UserId must be greater than 0.");
}
// Create the post
var createdPost = await CreatePostAsync(request.Config, props, cancellationToken);
// Populate output properties from the API response
props.Id = createdPost.Id;
_logger.LogInformation("Successfully created post with ID: {Id}", createdPost.Id);
return GetResponse(request);
}
protected override PostIdentifiers GetIdentifiers(Post properties) =>
new() { Title = properties.Title };
private async Task<PostResponse> CreatePostAsync(
Configuration configuration,
Post props,
CancellationToken ct
)
{
var createPayload = new
{
title = props.Title,
body = props.Body,
userId = props.UserId
};
_logger.LogInformation(
"Calling JSONPlaceholder API to create post: Title={Title}, Body={Body}, UserId={UserId}",
props.Title,
props.Body,
props.UserId
);
var response = await CallApiForResponse<PostResponse>(
configuration,
HttpMethod.Post,
PostsApiEndpoint,
ct,
createPayload
);
if (response == null)
{
throw new InvalidOperationException(
$"Failed to create post with title '{props.Title}'."
);
}
return response;
}
private class PostResponse
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public int UserId { get; set; }
}
}
Step 3 – Update the handler in program
Lastly, change the ResourceHandler
in the Program.cs
with:
// truncated
var builder = WebApplication.CreateBuilder();
builder.AddBicepExtensionHost(args);
builder
.Services.AddBicepExtension(
name: "ConfigManager",
version: "0.0.1",
isSingleton: true,
typeAssembly: typeof(Program).Assembly,
configurationType: typeof(Configuration)
)
.WithResourceHandler<PostHandler>();
var app = builder.Build();
app.MapBicepExtension();
await app.RunAsync();
Step 4 – Rebuild
Run the build script again:
.\build.ps1
Your extension now has a new resource type.
To use the new resource type, update the main.bicep
file with:
targetScope = 'local'
param baseUrl string = 'https://jsonplaceholder.typicode.com'
extension myExtension with {
baseUrl: baseUrl
}
// Create a post using the JSONPlaceholder API
resource myPost 'Post' = {
title: 'My First Post'
body: 'This is the content of my first post created via Bicep!'
userId: 1
}
// Create another post for a different user
resource userPost 'Post' = {
title: 'Welcome to JSONPlaceholder'
body: 'This is an example post demonstrating the Post resource handler.'
userId: 5
}
// Output the IDs assigned by the API
output firstPostId int = myPost.id
output secondPostId int = userPost.id
The main.bicepparam
can be left untouched, as we've hardcoded the baseUrl
in the main.bicep
file. When you run the bicep local-deploy
command, you can actually see a real result.

Documentation
So, you have developed your first extension. What about documentation?
You've probably noticed these custom attributes. If you open up the ConfigManager.csproj
file, notice the package reference added:
<ItemGroup>
<PackageReference Include="Azure.Bicep.Local.Extension" Version="0.38.5" />
<PackageReference Include="Bicep.LocalDeploy" Version="1.0.1" />
</ItemGroup>
The Bicep.LocalDeploy
package contains these custom attributes. These attributes can be read and transformed to produce Markdown files using the bicep-local-docgen
CLI utility. Let's take a look.
Follow the steps below to generate documentation:
- Open your terminal
- Install the CLI utility by running
dotnet tool install -g bicep-local-docgen
. - Run
bicep-local-docgen --source src -v
.

As you can see, the post.md
is created in the docs
directory. Take a look at the result in your editor.
Review
In this blog post, you learned:
- How to scaffold your first extension using
Bicep.LocalDeploy.Templates
. - Customized the extension with a real-world example.
- Produced documentation with ease using
bicep-local-docgen
.
For more references, check out the following links:
- Template package: Bicep.LocalDeploy.Templates.
- Core library: Bicep.LocalDeploy on NuGet.
- Example extension: Bicep Databricks extension.
- Documentation: Create documentation using
bicep-local-docgen
.
Final thoughts
The Bicep.LocalDeploy.Templates
removes that first friction when you start with your first Bicep extension development. You don't have to waste time on project structure or copying and pasting from examples. You generate a clean template and can start building using the build.ps1
automation script.
From here on, you can expand further, rebuild it, and ship the extension.