How to easily developer your first Bicep Local Deploy extension with Bicep.LocalDeploy.Templates

Learn to develop your first Bicep extension using templates

How to easily developer your first Bicep Local Deploy extension with Bicep.LocalDeploy.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:

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:

  1. Clone an existing example repository and strip out the domain logic.
  2. 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:

  1. Restores NuGet packages.
  2. Builds the project by default in Release mode.
  3. Publish extensions for cross-platform.
  4. 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:

  1. Open your terminal
  2. Install the CLI utility by running dotnet tool install -g bicep-local-docgen.
  3. 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:

  1. How to scaffold your first extension using Bicep.LocalDeploy.Templates.
  2. Customized the extension with a real-world example.
  3. Produced documentation with ease using bicep-local-docgen.

For more references, check out the following links:

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.