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 GoRestExplore 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
- 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/UserDefine 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:
- Create a new file
User.csin theUserdirectory. - 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:
- BicepFrontMatter (from Bicep.LocalDeploy): Adds YAML front matter for documentation sites.
- BicepDocHeading (from Bicep.LocalDeploy): Sets the resource title and description.
- BicepDocExample (from Bicep.LocalDeploy): Provides multiple usage examples.
- BicepDocCustom (from Bicep.LocalDeploy): Adds custom sections like security notes.
- ResourceType (from Bicep.Local.Extension.Types.Attributes): Identifies this as a Bicep resource type.
- 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.
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/UserHandlerCreate 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:
- Create a new file named
UserHandler.csin theUserHandlerdirectory. - 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:
- Environment variable(API_KEY): Takes precedence if set. It's useful for local development or DevOps pipelines.
- 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:
- Visit the GoRest site.
- Click the Sign in button in the top navigation.
- Choose one of the authentication providers:
- GitHub: Sign in with your GitHub account.
- Microsoft: Sign in with your Microsoft account.
- Google: Sign in with your Google account.
- After authentication, you'll be redirected back to GoRest.
- Click on your profile icon in the top-right corner.
- 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.ps1Test 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.emailCreate 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 tokenCreate 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": []
}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.bicepparamWhen 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 = $trueWhen 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
PreviewandCreateOrUpdatelogic. - 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.