There’s been a really cool experimental feature release on Bicep 0.37.4 – a simplified local extension with C#. I didn’t grasp the full potential of it before starting to play with it myself, but now I realised the huge potential it offers, and decided to share it with you all.
There is official sample, but it omits some steps, so it might not be that easy to pick up if you don’t have much experience with C# and Bicep, so I created a full sample how to create a local extension and everything you need to do it.
Let’s build together a Bicep local extension which gets your local IP, so you can whitelist it in the Bicep, without needing to do any scripting in your pipelines, but stay 100% within Bicep to do so. What we’ll do is that we’ll build and consume a .NET-based local extension for Bicep under the j4ni
namespace that pulls your machine’s public IP from the ipify API—end to end, in VS Code, on macOS or Windows. You’ll see how to scaffold, implement, publish, and run it locally with bicep local-deploy
.
Prerequisites
- .NET 9 SDK installed (Windows, macOS, or Linux). Download from dotnet.microsoft.com.
- Bicep CLI
v0.37.4
or later (please note, that this is different from az bicep, a separate tool:- Windows:
winget install Microsoft.Bicep
orchoco install bicep
- macOS:
brew tap azure/bicep; brew install bicep
- Linux: download the Linux binary from the GitHub releases and place it on your
PATH
.
- Windows:
- Visual Studio Code with the C# and Bicep extensions.
1. Create the Project
Open a terminal in your desired parent folder, then:dotnet new web -n J4NI.BicepLocalExtension
cd J4NI.BicepLocalExtension
dotnet restore
That creates you an empty ASP.NET Core project.
2. Configure the Project File
Create or replaceJ4NI.BicepLocalExtension.csproj
with:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>j4ni</AssemblyName>
<RootNamespace>J4NI</RootNamespace>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Bicep.Local.Extension" Version="0.37.4" />
</ItemGroup>
</Project>
This is C# project configuration, where the main point is that we define the assembly name and namespace, and define that we want to publish this as a single file.
3. Add Your Code Files
Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Bicep.Local.Extension.Host.Extensions;
using J4NI.BicepLocalExtension.Handlers;
var builder = WebApplication.CreateBuilder(args);
// Register the Bicep extension host
builder.AddBicepExtensionHost(args);
// Configure and add your local extension
builder.Services
.AddBicepExtension(
name: "j4ni",
version: "0.1.0",
isSingleton: true,
typeAssembly: typeof(Program).Assembly)
.WithResourceHandler<PublicIpHandler>();
var app = builder.Build();
// Map the extension endpoints
app.MapBicepExtension();
await app.RunAsync();
What’s happening in this section is that we register the middleware and services required to expose a Bicep “local deploy” endpoint. Then we declare our extension to the host. Then we set some parameters, like name
is the identifier used in Bicep files, version
lets you publish updates without cache conflicts, isSingleton
sets that single handler instance serves all requests and typeAssembly
points the host at your compiled DLL so it can discover resource types and handlers via reflection.
WithResourceHandler<PublicIpHandler>()
tells the host: “When a Bicep file declares a resource of type PublicIp
, route it to this handler class.”
var app = builder.Build(); app.MapBicepExtension();
finalizes the HTTP pipeline and maps a single POST endpoint (typically /
) that the Bicep CLI invokes under the covers of bicep local-deploy
.
Final step await app.RunAsync();
starts the Kestrel web server—your extension is now listening for Bicep to call it, perform resource operations, and return outputs.
Models.cs
using Azure.Bicep.Types.Concrete;
using Bicep.Local.Extension.Types.Attributes;
namespace J4NI.BicepLocalExtension
{
public class PublicIpIdentifiers
{
[TypeProperty("The resource name", ObjectTypePropertyFlags.Identifier | ObjectTypePropertyFlags.Required)]
public required string Name { get; set; }
}
[ResourceType("PublicIp")]
public class PublicIp : PublicIpIdentifiers
{
[TypeProperty("Your machine’s public IP address")]
public string? Output { get; set; }
}
}
Models.cs
defines the schema and metadata that let Bicep and your handler speak the same language—linking the declarative .bicep file to your imperative C# code.
Handlers/PublicIpHandler.cs
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Bicep.Types.Concrete; // for ResourceResponse
using Bicep.Local.Extension.Host.Handlers; // for TypedResourceHandler<T,U>, ResourceRequest
using Bicep.Local.Extension.Types.Attributes; // for [ResourceType], [TypeProperty]
using J4NI.BicepLocalExtension; // for PublicIp, PublicIpIdentifiers
namespace J4NI.BicepLocalExtension.Handlers
{
public class PublicIpHandler : TypedResourceHandler<PublicIp, PublicIpIdentifiers>
{
private static readonly HttpClient httpClient = new();
// 1. Preview is optional but recommended
protected override async Task<ResourceResponse> Preview(
ResourceRequest request,
CancellationToken cancellationToken)
{
await Task.CompletedTask;
return GetResponse(request);
}
// 2. CreateOrUpdate must be overridden
protected override async Task<ResourceResponse> CreateOrUpdate(
ResourceRequest request,
CancellationToken cancellationToken)
{
var ip = await httpClient.GetStringAsync("https://api.ipify.org", cancellationToken);
request.Properties.Output = ip.Trim();
return GetResponse(request);
}
// 3. Map your identifiers
protected override PublicIpIdentifiers GetIdentifiers(PublicIp properties) =>
new() { Name = properties.Name };
}
}
Here we do the actual hard work, and get the public IP of your computer.
4. Publish Native Binaries
From your project root, run:# macOS Apple Silicon
dotnet publish -c Release -r osx-arm64 --self-contained true /p:PublishSingleFile=true
# Linux x64
dotnet publish -c Release -r linux-x64 --self-contained true /p:PublishSingleFile=true
# Windows x64
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
You can create all this packages, even you are not running those, so you can deploy to your build machines or team member machines as well.
5. Pack the Extension
Now runbicep publish-extension
to bundle all RIDs into ./bin/j4ni
:
bicep publish-extension \
--bin-osx-arm64 ./bin/Release/osx-arm64/publish/j4ni \
--bin-linux-x64 ./bin/Release/linux-x64/publish/j4ni \
--bin-win-x64 ./bin/Release/win-x64/publish/j4ni.exe \
--target ./bin/j4ni \
--force
6. Configure Bicep for Local Deploy
Create abicepconfig.json
next to your Bicep template:
{
"experimentalFeaturesEnabled": {
"localDeploy": true
},
"extensions": {
"j4ni": "./bin/j4ni"
}
}
7. Write & Run Your Bicep Template
main.bicep
targetScope = 'local'
extension j4ni
resource pubIp 'PublicIp' = {
name: 'demo'
}
output publicIp string = pubIp.output
Create main.bicepparam
containing:
using 'main.bicep'
Finally, deploy locally:
bicep local-deploy main.bicepparam
You’ll see output similar to:
Output publicIp: 203.0.113.42 Resource pubIp (CreateOrUpdate): Succeeded Result: SucceededWe now have a working local extension that fetches your public IP without any scripting and you can use this with Bicep CLI anywhere! You could do your own for example setting database schema, extend the authorisation with Entra, or for example creating values for key vaults, all those things which were not possible with Bicep before! Hope you found this helpful!