Creating your first Bicep Local Deploy extension

With your environment set up, it's time to develop your first Bicep Local Deploy extension. In this module, you'll:

  • Scaffold a new extension project using the template.
  • Understand the project structure and the created files.
  • Define a custom resource module with documentation attributes.
  • Implement a basic resource handler.
  • Build your extension locally.
  • Write your first Bicep file using your custom resource.

Let's dive into it.

Scaffold your extension project

The fastest way to start building an extension is by using the unofficial project template. The template provides a project structure similar to other open-source projects, with all the components needed to get started right out of the bat. This eliminates minutes, let alone hours, of setup work from the start.

In the first lesson, you already created a simple demo extension project to verify your installation. Now, let's create a practical extension that leverages a real service with authentication and responds with actual data.

This free service, GoRest, allows you to authenticate using a token. Token-based authentication is one of the most common patterns in REST API services, making this an ideal exercise. To use GoRest, you'll need either a GitHub account, a Microsoft account, or a Google account to create an authentication token. If you already have one of these accounts, you're ready to start.

Create the project

The dotnet new command with the bicep-ld-tpl template creates a complete ready-to-use extension project.

To create a new extension project using this template, you can run:

# Create new extension project
dotnet new bicep-ld-tpl -n GoRest

# Navigate to the project
cd GoRest

Explore the project structure

Understanding the project structure helps you navigate and extend your codebase. The template organizes files by responsibility: models define resource schemas, handlers implement the logic, and the build script automates compilation and publishing the extension.

This separation of concerns makes it easy to find what you need and maintains clear boundaries between different aspects of your extension. The template creates a complete project structure:

GoRest/
 build.ps1                    # Build and publish script
 global.json                  # .NET SDK version configuration
 GlobalUsings.cs              # Global using directives
 Program.cs                   # Application entry point
 GoRest.csproj                # Project file
 Models/
    Configuration.cs          # Extension configuration
    SampleResource/
      SampleResource.cs       # Sample resource model (we'll replace this)
 Handlers/
    ResourceHandlerBase.cs    # Base handler with REST API helpers
    SampleHandler/
      SampleResourceHandler.cs # Sample resource handler (we'll replace this)

Key files explained

Let's examine the most important files in your extension project. Understanding what each file does and how they work together allows you to customize the template to your needs. These files form the foundation of every Bicep extension, and you'll interact with them frequently as you develop your extension.

Program.cs

The entry point that configures:

  • Dependency injection
  • Register extension
  • Map resource handlers

This file is where your extension comes to life. It tells Bicep what your extension is called, which resource types it provides, and how to handle requests for those resources.

The entry point that registers your extension handlers:

using Bicep.Local.Extension.Host.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using GoRest.Handlers.SampleHandler;
using GoRest.Models;

var builder = WebApplication.CreateBuilder();

builder.AddBicepExtensionHost(args);
builder
    .Services.AddBicepExtension(
        name: "GoRest",
        version: "0.0.1",
        isSingleton: true,
        typeAssembly: typeof(Program).Assembly,
        configurationType: typeof(Configuration)
    )
    .WithResourceHandler<SampleResourceHandler>();

var app = builder.Build();
app.MapBicepExtension();
await app.RunAsync();

Configuration.cs

The Configuration class defines default parameters that users specify when declaring your extension in Bicep. These parameters are always mandatory and are used widely amongst all resource types. Configuration properties can include base URLs, authentication tokens, and other default values.

Users set these once in the extension declaration, and the framework makes them available to all your resource handlers. When you scaffolded the project, the default configuration parameters are:

using Azure.Bicep.Types.Concrete;
using Bicep.Local.Extension.Types.Attributes;

namespace GoRest.Models;

public class Configuration
{
    [TypeProperty("The base URL for the API endpoint.", ObjectTypePropertyFlags.Required)]
    public required string BaseUrl { get; set; }
}

The REST API you'll be using requires an authentication token. That's why you can add an additional property Token:

[TypeProperty("The authentication token for GoRest API.", ObjectTypePropertyFlags.Required)]
public required string Token { get; set; }

ResourceHandlerBase.cs

A base class that is not mandatory, but highly likely to be used. It can encapsulate common HTTP client functionality, making it easier to call REST APIs from your handlers. This class provides helper methods for making HTTP requests, handling responses, deserializing JSON, and potentially handling errors.

By inheriting from this base class, your resource handlers can focus on simple logic rather than boilerplate each HTTP request and response.

The other key files will be explained in more detail in the following sections.

Define your resource model

Resource models are the heart of your extension. They define the properties of your resources (required or optional) and the outputs they produce. The model acts as a contract between Bicep and your extension. This is where you get IntelliSense, validation, and type safety in your VS Code editor.

Designing your models with clear property names and documentation attributes makes your extension easier to use. In the following subsection, you'll create a User resource that represents a user in the GoRest API system. This is a practical demonstration of how you can leverage Bicep's ecosystem of extensions and how they work with external REST APIs. The model will include properties like:

  • Name
  • Email
  • Gender
  • Status

Those are the properties that users can define and return in the output properties that are returned after deployment.

Create the model folder

Organizing your models in dedicated folders keeps your codebase clean. This structure becomes valuable as your extension grows and you add more resource types. Each resource type gets its own folder, depending on how the REST API organizes it.

To create the User folder, run the following command:

mkdir src/Models/User

Define the model class

Now you'll create the complete User model that defines the structure of your resource. This model includes two enums (UserGender and UserStatus) that constrains valid values. Because Bicep's Local Deploy doesn't support custom enum types in its type system, you'll see an attributed ([JsonConverter(typeof(JsonStringEnumConverter))]) added, allowing you to bridge the gap by converting your .NET enum values to and from strings during serialization.

Pay close attention to how the User class inherits from UserIdentifiers. This pattern separates identifying properties from the rest of the model, which the framework uses to track resource instances.

Follow the steps below to define the model class:

  1. Create a new file User.cs in the User directory.
  2. Add the following code snippet:
using Bicep.Local.Extension.Types.Attributes;
using System.Text.Json.Serialization;

namespace GoRest.Models.User;

public enum UserGender
{
    Male,
    Female
}

public enum UserStatus
{
    Active,
    Inactive
}

[BicepFrontMatter("category", "User Management")]
[BicepDocHeading("User", "Manages users in the GoRest API.")]
[BicepDocExample(
    "Creating a basic user",
    "This example creates a simple active user with required fields.",
    @"resource adminUser 'User' = {
  name: 'John Doe'
  email: 'john.doe@example.com'
  gender: 'Male'
  status: 'Active'
}")]
[BicepDocExample(
    "Creating multiple users",
    "This example creates multiple users with different settings.",
    @"resource adminUser 'User' = {
  name: 'Jane Admin'
  email: 'jane.admin@company.com'
  gender: 'Female'
  status: 'Active'
}

resource devUser 'User' = {
  name: 'Bob Developer'
  email: 'bob.dev@company.com'
  gender: 'Male'
  status: 'Active'
}")]
[BicepDocCustom(
    "User email requirements",
    @"User emails must be unique in the GoRest system. Key considerations:

- Email addresses must be valid and unique
- Use organization domain emails for better tracking
- Inactive users can be reactivated by updating their status
- Gender field accepts 'Male' or 'Female' values
- Status field accepts 'Active' or 'Inactive' values")]
[ResourceType("User")]
public class User : UserIdentifiers
{
    [TypeProperty("The full name of the user.", ObjectTypePropertyFlags.Required)]
    public required string Name { get; set; }

    [TypeProperty("The gender of the user.", ObjectTypePropertyFlags.Required)]
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public required UserGender? Gender { get; set; }

    [TypeProperty("The status of the user account.", ObjectTypePropertyFlags.Required)]
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public required UserStatus? Status { get; set; }

    [TypeProperty("The unique user ID assigned by GoRest.", ObjectTypePropertyFlags.ReadOnly)]
    public int Id { get; set; }
}

public class UserIdentifiers
{
    [TypeProperty("The unique email address of the user.", ObjectTypePropertyFlags.Required | ObjectTypePropertyFlags.Identifier)]
    public required string Email { get; set; }
}

Understanding property flags

The property flags you see are the mechanism for controlling how Bicep treats your resource properties. They determine whether a property is required, whether it can be set by users, or whether it is only returned as output. It also serves as a unique identifier (UserIdentifiers) for the resource.

Understanding these flags helps you design your resource models that behave correctly in Bicep files

The ObjectTypePropertyFlags enum controls property behavior:

  • Required: Property must be specified in Bicep.
  • Identifier: Used to uniquely identify the resource.
  • ReadOnly: Output property, cannot be set by users.
  • None: Optional input property.

Documentation attributes in action

You already see documentation attributes added. These attributes transform your .NET code into comprehensive documentation. By decorating your models with these attributes, you embed examples, descriptions, and custom sections directly in your code.

When you run the bicep-local-docgen tool, it extracts all this metadata and generates Markdown files that users can reference when working with your extension.

Notice how multiple attributes from two different libraries are defined:

  1. BicepFrontMatter (from Bicep.LocalDeploy): Adds YAML front matter for documentation sites.
  2. BicepDocHeading (from Bicep.LocalDeploy): Sets the resource title and description.
  3. BicepDocExample (from Bicep.LocalDeploy): Provides multiple usage examples.
  4. BicepDocCustom (from Bicep.LocalDeploy): Adds custom sections like security notes.
  5. ResourceType (from Bicep.Local.Extension.Types.Attributes): Identifies this as a Bicep resource type.
  6. TypeProperty (from Bicep.Local.Extension.Types.Attributes): Documents each property.

This combination of Bicep attributes and documentation-specific attributes gives you metadata for both runtime behavior and documentation generation.

Implement the resource handler

Resource handlers are the hardworking components of your extension. While your model defines what your resource looks like, handlers define how it behaves. Handlers translate Bicep's declarative resource requests into imperative API calls. It creates, updates, or deletes resources. Well-written handlers make your resources feel more predictable to Bicep users.

💡
Imperative translates declarative Bicep into step-by-step instructions.

Every handler implements at least three key methods: Preview (checks the current state before deployment), CreateOrUpdate (provisions or modifies the resource), and GetIdentifiers (extracts unique identifiers). These methods form the resource lifecycle, allowing Bicep to manage your custom resources with the same declarative approach it uses for Azure resources.

Create the handler folder

Just as models benefit from being organized, handlers do too. This keeps handler code, helper classes, and any handler-specific types together.

To create the folder, run the following command:

mkdir src/Handlers/UserHandler

Create UserHandler.cs

The following handler implementation demonstrates the resource lifecycle for user management in the GoRest API. It shows how to check whether a user exists by email, create a new user, update existing users, and populate output properties (such as the generated user ID).

The handler uses the base class's HTTP client helper to make REST API calls and logs operations for debugging. Follow the steps below to create the UserHandler.cs:

  1. Create a new file named UserHandler.cs in the UserHandler directory.
  2. Add the following code snippet:
using Microsoft.Extensions.Logging;
using GoRest.Models;
using GoRest.Models.User;

namespace GoRest.Handlers.UserHandler;

public class UserHandler : ResourceHandlerBase<User, UserIdentifiers>
{
    private const string UsersApiEndpoint = "/public/v2/users";

    public UserHandler(ILogger<UserHandler> logger)
        : base(logger) { }

    protected override async Task<ResourceResponse> Preview(
        ResourceRequest request,
        CancellationToken cancellationToken
    )
    {
        var props = request.Properties;
        
        _logger.LogInformation("Previewing user: {Email}, Name: {Name}", props.Email, props.Name);

        var existing = await GetUserByEmailAndNameAsync(
            request.Config,
            props.Email,
            props.Name,
            cancellationToken
        );

        if (existing != null)
        {
            _logger.LogInformation("User exists with ID: {Id}, populating outputs", existing.Id);
            
            // Populate output properties from existing resource
            props.Id = existing.Id;
            props.Name = existing.Name;
            props.Gender = existing.Gender;
            props.Status = existing.Status;
        }
        else
        {
            _logger.LogInformation("User does not exist, will be created");
        }

        return GetResponse(request);
    }

    protected override async Task<ResourceResponse> CreateOrUpdate(
        ResourceRequest request,
        CancellationToken cancellationToken
    )
    {
        var props = request.Properties;

        _logger.LogInformation("Ensuring user: {Email}, Name: {Name}", props.Email, props.Name);

        var existing = await GetUserByEmailAndNameAsync(request.Config, props.Email, props.Name, cancellationToken);

        if (existing == null)
        {
            _logger.LogInformation("Creating new user: {Email}", props.Email);
            var created = await CreateUserAsync(request.Config, props, cancellationToken);
            
            // Populate output properties from the created user
            props.Id = created.Id;
            props.Name = created.Name;
            props.Gender = created.Gender;
            props.Status = created.Status;
        }
        else
        {
            _logger.LogInformation("Updating existing user with ID: {Id}", existing.Id);
            
            try
            {
                await UpdateUserAsync(request.Config, existing.Id, props, cancellationToken);
                props.Id = existing.Id;
            }
            catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
            {
                // This can happen if we're trying to update with the same email that already exists
                _logger.LogWarning("Update returned 422 (Unprocessable Entity), treating as successful: {Message}", ex.Message);
                props.Id = existing.Id;
            }
        }

        return GetResponse(request);
    }

    protected override UserIdentifiers GetIdentifiers(User properties) =>
        new() { Email = properties.Email };

    private async Task<User?> GetUserByEmailAndNameAsync(
        Configuration configuration,
        string email,
        string name,
        CancellationToken ct
    )
    {
        try
        {
            // GoRest API doesn't support filtering by email/name in query params
            // Fetch all users and search by email OR name
            _logger.LogInformation("Fetching all users to search for email: {Email} OR name: {Name}", email, name);
            
            var response = await CallApiForResponse<List<User>>(
                configuration,
                HttpMethod.Get,
                UsersApiEndpoint,
                ct
            );

            if (response != null)
            {
                _logger.LogInformation("API returned {Count} users", response.Count);
            }
            else
            {
                _logger.LogWarning("API returned null response");
                return null;
            }

            // Search for user by email OR name (case-insensitive)
            var match = response.FirstOrDefault(u => 
                string.Equals(u.Email, email, StringComparison.OrdinalIgnoreCase) ||
                string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase));

            if (match != null)
            {
                _logger.LogInformation("Found matching user: ID={Id}, Email={Email}, Name={Name}", 
                    match.Id, match.Email, match.Name);
            }
            else
            {
                _logger.LogInformation("No matching user found for email={Email} OR name={Name}", email, name);
            }

            return match;
        }
        catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            return null;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to get user by email and name: {Email}, {Name}", email, name);
            return null;
        }
    }

    private async Task<User> CreateUserAsync(
        Configuration configuration,
        User user,
        CancellationToken ct
    )
    {
        var createPayload = new
        {
            name = user.Name,
            email = user.Email,
            gender = user.Gender?.ToString().ToLowerInvariant(),
            status = user.Status?.ToString().ToLowerInvariant()
        };

        var response = await CallApiForResponse<User>(
            configuration,
            HttpMethod.Post,
            UsersApiEndpoint,
            ct,
            createPayload
        );

        return response ?? throw new InvalidOperationException("Failed to create user");
    }

    private async Task UpdateUserAsync(
        Configuration configuration,
        int userId,
        User user,
        CancellationToken ct
    )
    {
        var updatePayload = new
        {
            name = user.Name,
            email = user.Email,
            gender = user.Gender?.ToString().ToLowerInvariant(),
            status = user.Status?.ToString().ToLowerInvariant()
        };

        await CallApiForResponse<User>(
            configuration,
            HttpMethod.Put,
            $"{UsersApiEndpoint}/{userId}",
            ct,
            updatePayload
        );
    }
}

Understanding handler methods

Each handler method serves a specific purpose. Understanding when each method is called and what it should do helps you build reliable extensions. These methods work together to provide Bicep with the information it needs to manage resources declaratively.

Preview

Preview is called before any changes are made. This gives Bicep a chance to show users what will happen during deployment. This is your opportunity to check if a resource already exists and retrieve its current properties. Preview should never make changes.

Use this to:

  • Retrieve current resource properties.
  • Populate output properties.
  • Validate resource state.

CreateOrUpdate

CreateOrUpdate is where the actual resource provisioning happens. This method is called during deployment to apply the desired state specified in Bicep. It should check if the resource exists, create it if it doesn't, or update it if it does. This method must be idempotent, meaning every time you call the resource, the same result should be returned.

This method should:

  • Check if the resource exists.
  • Create a new resource or update an existing one.
  • Return the final resource state with outputs.

GetIdentifiers

The GetIdentifiers method is the important method that extracts the unique identifiers from a resource. The framework uses these identifiers to track resource instances, manage resource dependencies, and maintain state across deployments. Identifiers must be stable and unique

The framework uses it to:

  • Track resource instances.
  • Handle resource dependencies.
  • Manage resource state.

Register your handler

With your model and handler complete, you need to tell your extension about them. Registration is the step that connects your handler to the Bicep framework. When this happens, the resource type is available for use in Bicep files. The WithResourceHandler method wires up dependency injection, maps the HTTP requests, and enables the framework to discover your resource type.

Update Program.cs to use your new handler:

using Bicep.Local.Extension.Host.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using GoRest.Handlers.SampleHandler;
using GoRest.Models;
using GoRest.Handlers.UserHandler;

var builder = WebApplication.CreateBuilder();

builder.AddBicepExtensionHost(args);
builder
    .Services.AddBicepExtension(
        name: "GoRest",
        version: "0.0.1",
        isSingleton: true,
        typeAssembly: typeof(Program).Assembly,
        configurationType: typeof(Configuration)
    )
    .WithResourceHandler<UserHandler>();

var app = builder.Build();
app.MapBicepExtension();
await app.RunAsync();

Authenticating against REST API

Before building your extension, there's one more thing to do. You need to understand how authentication works with the GoRest API. The base class (ResourceHandlerBase.cs) class includes built-in authentication support using an environment variable.

Because you've added the Token property in the model, you provide two capabilities for supplying credentials. The original base class code's authentication flow is:

// Try API_KEY environment variable for authentication
var apiKey = Environment.GetEnvironmentVariable("API_KEY");
if (!string.IsNullOrWhiteSpace(apiKey))
{
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
        "Bearer",
        apiKey
    );
}

return client;

However, you can't expect all users to set this environment variable up front. That's where you can change the above code to:

// Try API_KEY environment variable for authentication, otherwise use configuration token
var apiKey = Environment.GetEnvironmentVariable("API_KEY");
var token = apiKey ?? configuration.Token;

if (!string.IsNullOrWhiteSpace(token))
{
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
        "Bearer",
        token
    );
}

return client;

This approach gives you the best of both worlds to supply your authentication token:

  1. Environment variable(API_KEY): Takes precedence if set. It's useful for local development or DevOps pipelines.
  2. Configuration parameter (Token): Specified in your Bicep parameters.

Getting your GoRest token

To use the GoRest API, you can already retrieve an authentication token. Here's how to obtain one:

  1. Visit the GoRest site.
  2. Click the Sign in button in the top navigation.
  3. Choose one of the authentication providers:
    1. GitHub: Sign in with your GitHub account.
    2. Microsoft: Sign in with your Microsoft account.
    3. Google: Sign in with your Google account.
  4. After authentication, you'll be redirected back to GoRest.
  5. Click on your profile icon in the top-right corner.
  6. Your access token will be displayed here.

Store this value for later.

Building your extension

Building your extension compiles your .NET code and packages it with its dependencies. In the end, a deployment-ready extension is produced in the output directory. The build automation script you've played with can be reused to automate this entire process. A successful build means your extension is ready to be referenced from Bicep files.

To build and publish (locally) the extension, run the following in a PowerShell terminal session:

.\build.ps1

Test your extension with Bicep

The moment of truth. Now that the extension is published, you can test it with an actual Bicep file. You'll write your Bicep code as you usually would for Azure resources. When you edit your Bicep file in the VS Code editor, you notice the IntelliSense kicks in. Now let's use your extension in a Bicep file.

Create main.bicep

This Bicpe file demonstrates the full capabilities of your User resource. Notice how it declares the extension with configuration parameters (baseUrl and token), defines a resource with all required properties, and outputs values from read-only properties, such as the generated user ID.

targetScope = 'local'

param baseUrl string
@secure()
param token string

extension gorest with {
  baseUrl: baseUrl
  token: token
}

resource adminUser 'User' = {
  name: 'John Doe'
  email: 'john.doe@example.com'
  gender: 'Male'
  status: 'Active'
}

output adminUserId int = adminUser.id
output adminUserEmail string = adminUser.email
â„šī¸
Don't worry about the inline errors you'll see.

Create main.bicepparam

Bicep Local Deploy doesn't support direct deployments. Instead, it relies on .bicepparam files to be used for deployment. Here's where the earlier fetched token comes in:

using 'main.bicep'

param baseUrl = 'https://gorest.co.in'
param token = 'YOUR_GOREST_TOKEN_HERE'  // Replace with your actual token

Create bicepconfig.json (optional)

Remember the squiggly errors in your main.bicep file. This is because Bicep Local Deploy is an experimental feature. When Bicep Local Deploy is in GA (General Availability), this step is no longer mandatory. Once you configure the following, Bicep can discover your local extension, load its type definitions, and provide IntelliSense for your custom resource types:

{
  "experimentalFeaturesEnabled": {
    "localDeploy": true
  },
  "extensions": {
    "GoRestExtension": "output/my-extension"
  },
  "implicitExtensions": []
}
💡
If you want to change the name (my-extension), you can change the <AssemblyName> in the .csproj file.

Now that everything is in place, run the following command:

bicep local-deploy main.bicepparam

When the deployment is successful, you'll see a user ID returned:

╭───────────â”Ŧ──────────â”Ŧ───────────╮
│ Resource  │ Duration │ Status    │
├───────────â”ŧ──────────â”ŧ───────────┤
│ adminUser │ 0.9s     │ Succeeded │
╰───────────┴──────────┴───────────╯
╭─────────────â”Ŧ─────────╮
│ Output      │ Value   │
├─────────────â”ŧ─────────┤
│ adminUserId │ 8204520 │
╰─────────────┴─────────╯

Troubleshooting

Even whilst writing these lessons, issues were encountered. To debug extensions, it's pretty straightforward by adding an environment variable in your existing session:

$env:BICEP_TRACING_ENABLED = $true

When this environment variable is set, additional logging is shown, including the _logger messages.

Summary

In this module, you:

  • Scaffolded an extension project from .NET template.
  • Created a custom resource model with documentation attributes.
  • Implemented a resource handler with Preview and CreateOrUpdate logic.
  • Registered your handler in the extension.

Next steps

Your extension is functional, but there's more to learn. In the next lessons, you'll:

  • Add testing in your project structure.
  • Create your documentation.
  • Publish the actual extension to a container registry.