Part 53: Writing a HomeLab Plugin — End to End
"A plugin is a NuGet that ships [Injectable] services. Everything else is convention."
Why
Part 10 defined the plugin system: IHomeLabPlugin, role-shaped contributor interfaces, the manifest, the discovery, the DI registration. This part is the end-to-end walkthrough of writing a plugin from scratch. By the end, you should be able to ship a NuGet that adds a meaningful new capability to HomeLab in an afternoon.
The plugin we will build: Cloudflare DNS provider for HomeLab. It adds an IDnsProvider implementation that uses the Cloudflare API to manage records for users who own a real domain and want to host DevLab on a public hostname (with proper Let's Encrypt certs via DNS-01).
Step 1: Scaffold the project
$ dotnet new classlib -n FrenchExDev.HomeLab.Plugin.Cloudflare
$ cd FrenchExDev.HomeLab.Plugin.Cloudflare
$ dotnet add package FrenchExDev.Net.HomeLab.PluginSdk
$ dotnet add package FrenchExDev.Net.Injectable
$ dotnet add package FrenchExDev.Net.HttpClient
$ dotnet add package FrenchExDev.Net.Result$ dotnet new classlib -n FrenchExDev.HomeLab.Plugin.Cloudflare
$ cd FrenchExDev.HomeLab.Plugin.Cloudflare
$ dotnet add package FrenchExDev.Net.HomeLab.PluginSdk
$ dotnet add package FrenchExDev.Net.Injectable
$ dotnet add package FrenchExDev.Net.HttpClient
$ dotnet add package FrenchExDev.Net.ResultThe PluginSdk package gives you the IHomeLabPlugin interface, the IPluginContext type, the role interfaces (IDnsProvider, etc.), and the Result<T> types HomeLab uses. It is the only HomeLab dependency a plugin needs — everything else is wired through DI by the host.
Step 2: The plugin entry point
// CloudflarePlugin.cs
using FrenchExDev.Net.HomeLab.PluginSdk;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare;
public sealed class CloudflarePlugin : IHomeLabPlugin
{
public string Name => "FrenchExDev.HomeLab.Plugin.Cloudflare";
public string Version => "1.0.0";
public string Description => "Cloudflare DNS provider for HomeLab";
public void Initialize(IPluginContext context)
{
context.Logger.LogInformation("Cloudflare DNS plugin {Version} initialized", Version);
// The IDnsProvider implementation is auto-registered via [Injectable].
// Nothing else to do here.
}
}// CloudflarePlugin.cs
using FrenchExDev.Net.HomeLab.PluginSdk;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare;
public sealed class CloudflarePlugin : IHomeLabPlugin
{
public string Name => "FrenchExDev.HomeLab.Plugin.Cloudflare";
public string Version => "1.0.0";
public string Description => "Cloudflare DNS provider for HomeLab";
public void Initialize(IPluginContext context)
{
context.Logger.LogInformation("Cloudflare DNS plugin {Version} initialized", Version);
// The IDnsProvider implementation is auto-registered via [Injectable].
// Nothing else to do here.
}
}The entry point is a stub. Its only job is to log a startup message and let the host know the plugin is loaded. The actual contribution is in the next file.
Step 3: The Cloudflare API client
// ICloudflareApi.cs
using FrenchExDev.Net.HttpClient;
using FrenchExDev.Net.Result;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare;
[TypedHttpClient(BaseUri = "https://api.cloudflare.com/client/v4")]
public partial interface ICloudflareApi
{
[Get("/zones/{zoneId}/dns_records")]
Task<Result<CloudflareListResponse>> ListRecordsAsync(
string zoneId,
[Header("Authorization")] string token,
[Query("name")] string? nameFilter = null,
CancellationToken ct = default);
[Post("/zones/{zoneId}/dns_records")]
Task<Result<CloudflareCreateResponse>> CreateRecordAsync(
string zoneId,
[Body] CloudflareCreateRequest request,
[Header("Authorization")] string token,
CancellationToken ct = default);
[Delete("/zones/{zoneId}/dns_records/{recordId}")]
Task<Result<CloudflareDeleteResponse>> DeleteRecordAsync(
string zoneId,
string recordId,
[Header("Authorization")] string token,
CancellationToken ct = default);
}
public sealed record CloudflareCreateRequest(string Type, string Name, string Content, int Ttl = 60, bool Proxied = false);
public sealed record CloudflareListResponse(IReadOnlyList<CloudflareDnsRecord> Result);
public sealed record CloudflareDnsRecord(string Id, string Name, string Type, string Content);
public sealed record CloudflareCreateResponse(CloudflareDnsRecord Result, bool Success);
public sealed record CloudflareDeleteResponse(bool Success);// ICloudflareApi.cs
using FrenchExDev.Net.HttpClient;
using FrenchExDev.Net.Result;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare;
[TypedHttpClient(BaseUri = "https://api.cloudflare.com/client/v4")]
public partial interface ICloudflareApi
{
[Get("/zones/{zoneId}/dns_records")]
Task<Result<CloudflareListResponse>> ListRecordsAsync(
string zoneId,
[Header("Authorization")] string token,
[Query("name")] string? nameFilter = null,
CancellationToken ct = default);
[Post("/zones/{zoneId}/dns_records")]
Task<Result<CloudflareCreateResponse>> CreateRecordAsync(
string zoneId,
[Body] CloudflareCreateRequest request,
[Header("Authorization")] string token,
CancellationToken ct = default);
[Delete("/zones/{zoneId}/dns_records/{recordId}")]
Task<Result<CloudflareDeleteResponse>> DeleteRecordAsync(
string zoneId,
string recordId,
[Header("Authorization")] string token,
CancellationToken ct = default);
}
public sealed record CloudflareCreateRequest(string Type, string Name, string Content, int Ttl = 60, bool Proxied = false);
public sealed record CloudflareListResponse(IReadOnlyList<CloudflareDnsRecord> Result);
public sealed record CloudflareDnsRecord(string Id, string Name, string Type, string Content);
public sealed record CloudflareCreateResponse(CloudflareDnsRecord Result, bool Success);
public sealed record CloudflareDeleteResponse(bool Success);The interface is partial. The [TypedHttpClient] source generator emits the implementation. The plugin author writes zero HttpClient.GetAsync calls.
Step 4: The DNS provider
// CloudflareDnsProvider.cs
using FrenchExDev.Net.HomeLab.PluginSdk;
using FrenchExDev.Net.Injectable;
using FrenchExDev.Net.Result;
using Microsoft.Extensions.Options;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare;
public sealed class CloudflareConfig
{
public string ZoneId { get; init; } = "";
public string ApiTokenSecretKey { get; init; } = "CLOUDFLARE_API_TOKEN";
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class CloudflareDnsProvider : IDnsProvider
{
public string Name => "cloudflare";
private readonly ICloudflareApi _api;
private readonly CloudflareConfig _config;
private readonly ISecretStore _secrets;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
public CloudflareDnsProvider(
ICloudflareApi api,
IOptions<CloudflareConfig> config,
ISecretStore secrets,
IHomeLabEventBus events,
IClock clock)
{
_api = api;
_config = config.Value;
_secrets = secrets;
_events = events;
_clock = clock;
}
public async Task<Result> AddAsync(string hostname, string ip, CancellationToken ct)
{
var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
if (token.IsFailure) return token.Map();
var bearer = $"Bearer {token.Value}";
var result = await _api.CreateRecordAsync(
_config.ZoneId,
new CloudflareCreateRequest("A", hostname, ip, Ttl: 60, Proxied: false),
bearer, ct);
if (result.IsFailure) return result.Map();
await _events.PublishAsync(new DnsEntryAdded(hostname, ip, "cloudflare", _clock.UtcNow), ct);
return Result.Success();
}
public async Task<Result> RemoveAsync(string hostname, CancellationToken ct)
{
var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
if (token.IsFailure) return token.Map();
var bearer = $"Bearer {token.Value}";
var list = await _api.ListRecordsAsync(_config.ZoneId, bearer, hostname, ct);
if (list.IsFailure) return list.Map();
foreach (var record in list.Value.Result)
{
var del = await _api.DeleteRecordAsync(_config.ZoneId, record.Id, bearer, ct);
if (del.IsFailure) return del.Map();
}
await _events.PublishAsync(new DnsEntryRemoved(hostname, "cloudflare", _clock.UtcNow), ct);
return Result.Success();
}
public async Task<Result<IReadOnlyList<DnsEntry>>> ListAsync(CancellationToken ct)
{
var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
if (token.IsFailure) return token.Map<IReadOnlyList<DnsEntry>>();
var bearer = $"Bearer {token.Value}";
var list = await _api.ListRecordsAsync(_config.ZoneId, bearer, ct: ct);
if (list.IsFailure) return list.Map<IReadOnlyList<DnsEntry>>();
var entries = list.Value.Result
.Where(r => r.Type == "A")
.Select(r => new DnsEntry(r.Name, r.Content, "cloudflare"))
.ToList();
return Result.Success<IReadOnlyList<DnsEntry>>(entries);
}
}// CloudflareDnsProvider.cs
using FrenchExDev.Net.HomeLab.PluginSdk;
using FrenchExDev.Net.Injectable;
using FrenchExDev.Net.Result;
using Microsoft.Extensions.Options;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare;
public sealed class CloudflareConfig
{
public string ZoneId { get; init; } = "";
public string ApiTokenSecretKey { get; init; } = "CLOUDFLARE_API_TOKEN";
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class CloudflareDnsProvider : IDnsProvider
{
public string Name => "cloudflare";
private readonly ICloudflareApi _api;
private readonly CloudflareConfig _config;
private readonly ISecretStore _secrets;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
public CloudflareDnsProvider(
ICloudflareApi api,
IOptions<CloudflareConfig> config,
ISecretStore secrets,
IHomeLabEventBus events,
IClock clock)
{
_api = api;
_config = config.Value;
_secrets = secrets;
_events = events;
_clock = clock;
}
public async Task<Result> AddAsync(string hostname, string ip, CancellationToken ct)
{
var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
if (token.IsFailure) return token.Map();
var bearer = $"Bearer {token.Value}";
var result = await _api.CreateRecordAsync(
_config.ZoneId,
new CloudflareCreateRequest("A", hostname, ip, Ttl: 60, Proxied: false),
bearer, ct);
if (result.IsFailure) return result.Map();
await _events.PublishAsync(new DnsEntryAdded(hostname, ip, "cloudflare", _clock.UtcNow), ct);
return Result.Success();
}
public async Task<Result> RemoveAsync(string hostname, CancellationToken ct)
{
var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
if (token.IsFailure) return token.Map();
var bearer = $"Bearer {token.Value}";
var list = await _api.ListRecordsAsync(_config.ZoneId, bearer, hostname, ct);
if (list.IsFailure) return list.Map();
foreach (var record in list.Value.Result)
{
var del = await _api.DeleteRecordAsync(_config.ZoneId, record.Id, bearer, ct);
if (del.IsFailure) return del.Map();
}
await _events.PublishAsync(new DnsEntryRemoved(hostname, "cloudflare", _clock.UtcNow), ct);
return Result.Success();
}
public async Task<Result<IReadOnlyList<DnsEntry>>> ListAsync(CancellationToken ct)
{
var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
if (token.IsFailure) return token.Map<IReadOnlyList<DnsEntry>>();
var bearer = $"Bearer {token.Value}";
var list = await _api.ListRecordsAsync(_config.ZoneId, bearer, ct: ct);
if (list.IsFailure) return list.Map<IReadOnlyList<DnsEntry>>();
var entries = list.Value.Result
.Where(r => r.Type == "A")
.Select(r => new DnsEntry(r.Name, r.Content, "cloudflare"))
.ToList();
return Result.Success<IReadOnlyList<DnsEntry>>(entries);
}
}The provider:
- Reads the API token from
ISecretStore(the user is expected to store it underCLOUDFLARE_API_TOKENviahomelab secret set) - Calls the typed API client for each operation
- Publishes the same
DnsEntryAdded/DnsEntryRemovedevents as the built-in providers - Returns
Result<T>everywhere
Step 5: The manifest
{
"$schema": "https://frenchexdev.lab/schemas/homelab-plugin.schema.json",
"name": "FrenchExDev.HomeLab.Plugin.Cloudflare",
"version": "1.0.0",
"description": "Cloudflare DNS provider for HomeLab",
"homelabApiVersion": "^1.0",
"entryPoint": "FrenchExDev.HomeLab.Plugin.Cloudflare.CloudflarePlugin",
"provides": {
"dnsProviders": ["cloudflare"]
},
"requires": {
"secrets": ["CLOUDFLARE_API_TOKEN"],
"config": ["cloudflare.zone_id"]
}
}{
"$schema": "https://frenchexdev.lab/schemas/homelab-plugin.schema.json",
"name": "FrenchExDev.HomeLab.Plugin.Cloudflare",
"version": "1.0.0",
"description": "Cloudflare DNS provider for HomeLab",
"homelabApiVersion": "^1.0",
"entryPoint": "FrenchExDev.HomeLab.Plugin.Cloudflare.CloudflarePlugin",
"provides": {
"dnsProviders": ["cloudflare"]
},
"requires": {
"secrets": ["CLOUDFLARE_API_TOKEN"],
"config": ["cloudflare.zone_id"]
}
}The manifest is a JSON file that ships in the NuGet package alongside the DLL. HomeLab's PluginHost reads it at discovery time and validates it against the schema.
Step 6: The csproj wiring
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PackageId>FrenchExDev.HomeLab.Plugin.Cloudflare</PackageId>
<Version>1.0.0</Version>
<Authors>FrenchExDev</Authors>
<Description>Cloudflare DNS provider for HomeLab</Description>
<PackageTags>homelab plugin dns cloudflare</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FrenchExDev.Net.HomeLab.PluginSdk" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Injectable" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.HttpClient" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Result" Version="1.*" />
</ItemGroup>
<ItemGroup>
<None Include="homelab.plugin.json" Pack="true" PackagePath="content" />
</ItemGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PackageId>FrenchExDev.HomeLab.Plugin.Cloudflare</PackageId>
<Version>1.0.0</Version>
<Authors>FrenchExDev</Authors>
<Description>Cloudflare DNS provider for HomeLab</Description>
<PackageTags>homelab plugin dns cloudflare</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FrenchExDev.Net.HomeLab.PluginSdk" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Injectable" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.HttpClient" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Result" Version="1.*" />
</ItemGroup>
<ItemGroup>
<None Include="homelab.plugin.json" Pack="true" PackagePath="content" />
</ItemGroup>
</Project>The <None Include="homelab.plugin.json" Pack="true" PackagePath="content"> puts the manifest into the NuGet package's content/ directory, where HomeLab's plugin host knows to look for it.
Step 7: The test harness
The plugin SDK ships a PluginTestHost that builds a minimal HomeLab DI container with the plugin loaded:
// CloudflareDnsProviderTests.cs
using FrenchExDev.Net.HomeLab.PluginSdk.Testing;
using FluentAssertions;
using Xunit;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare.Tests;
public sealed class CloudflareDnsProviderTests
{
[Fact]
public async Task add_record_calls_cloudflare_api_with_correct_payload()
{
var api = new ScriptedCloudflareApi();
api.OnCreate(zoneId: "test-zone", name: "gitlab.frenchexdev.lab", ip: "1.2.3.4",
returnRecordId: "rec-1");
var secrets = new InMemorySecretStore();
await secrets.WriteAsync("CLOUDFLARE_API_TOKEN", "fake-token", default);
using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin(),
services =>
{
services.Replace(ServiceDescriptor.Singleton<ICloudflareApi>(api));
services.Replace(ServiceDescriptor.Singleton<ISecretStore>(secrets));
services.Configure<CloudflareConfig>(c => c.ZoneId = "test-zone");
});
var provider = host.GetService<IDnsProvider>();
provider.Name.Should().Be("cloudflare");
var result = await provider.AddAsync("gitlab.frenchexdev.lab", "1.2.3.4", default);
result.IsSuccess.Should().BeTrue();
api.Calls.Should().ContainSingle(c =>
c.Method == "Create" &&
c.Name == "gitlab.frenchexdev.lab" &&
c.Content == "1.2.3.4");
}
[Fact]
public async Task missing_api_token_returns_failure()
{
using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin(),
services =>
{
services.Replace(ServiceDescriptor.Singleton<ICloudflareApi>(new ScriptedCloudflareApi()));
services.Replace(ServiceDescriptor.Singleton<ISecretStore>(new InMemorySecretStore()));
services.Configure<CloudflareConfig>(c => c.ZoneId = "z");
});
var provider = host.GetService<IDnsProvider>();
var result = await provider.AddAsync("x.lab", "1.2.3.4", default);
result.IsFailure.Should().BeTrue();
result.Errors.Should().Contain(e => e.Contains("CLOUDFLARE_API_TOKEN"));
}
[Fact]
public void plugin_initializes_without_error_against_minimal_context()
{
using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin());
host.Should().NotBeNull();
}
}// CloudflareDnsProviderTests.cs
using FrenchExDev.Net.HomeLab.PluginSdk.Testing;
using FluentAssertions;
using Xunit;
namespace FrenchExDev.HomeLab.Plugin.Cloudflare.Tests;
public sealed class CloudflareDnsProviderTests
{
[Fact]
public async Task add_record_calls_cloudflare_api_with_correct_payload()
{
var api = new ScriptedCloudflareApi();
api.OnCreate(zoneId: "test-zone", name: "gitlab.frenchexdev.lab", ip: "1.2.3.4",
returnRecordId: "rec-1");
var secrets = new InMemorySecretStore();
await secrets.WriteAsync("CLOUDFLARE_API_TOKEN", "fake-token", default);
using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin(),
services =>
{
services.Replace(ServiceDescriptor.Singleton<ICloudflareApi>(api));
services.Replace(ServiceDescriptor.Singleton<ISecretStore>(secrets));
services.Configure<CloudflareConfig>(c => c.ZoneId = "test-zone");
});
var provider = host.GetService<IDnsProvider>();
provider.Name.Should().Be("cloudflare");
var result = await provider.AddAsync("gitlab.frenchexdev.lab", "1.2.3.4", default);
result.IsSuccess.Should().BeTrue();
api.Calls.Should().ContainSingle(c =>
c.Method == "Create" &&
c.Name == "gitlab.frenchexdev.lab" &&
c.Content == "1.2.3.4");
}
[Fact]
public async Task missing_api_token_returns_failure()
{
using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin(),
services =>
{
services.Replace(ServiceDescriptor.Singleton<ICloudflareApi>(new ScriptedCloudflareApi()));
services.Replace(ServiceDescriptor.Singleton<ISecretStore>(new InMemorySecretStore()));
services.Configure<CloudflareConfig>(c => c.ZoneId = "z");
});
var provider = host.GetService<IDnsProvider>();
var result = await provider.AddAsync("x.lab", "1.2.3.4", default);
result.IsFailure.Should().BeTrue();
result.Errors.Should().Contain(e => e.Contains("CLOUDFLARE_API_TOKEN"));
}
[Fact]
public void plugin_initializes_without_error_against_minimal_context()
{
using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin());
host.Should().NotBeNull();
}
}The PluginTestHost wraps a ServiceCollection that has AddHomeLab() (via the SDK), the plugin's [Injectable] types registered, and a stub IPluginContext. The plugin author writes their tests against this host without spinning up a real lab.
Step 8: Pack and publish
$ dotnet pack -c Release -o ./out
$ dotnet nuget push out/FrenchExDev.HomeLab.Plugin.Cloudflare.1.0.0.nupkg \
--source https://baget.frenchexdev.lab/v3/index.json \
--api-key $BAGET_API_KEY$ dotnet pack -c Release -o ./out
$ dotnet nuget push out/FrenchExDev.HomeLab.Plugin.Cloudflare.1.0.0.nupkg \
--source https://baget.frenchexdev.lab/v3/index.json \
--api-key $BAGET_API_KEYThe plugin is on the feed. Any HomeLab user can now install it:
$ dotnet add package FrenchExDev.HomeLab.Plugin.Cloudflare --source https://baget.frenchexdev.lab/v3/index.json$ dotnet add package FrenchExDev.HomeLab.Plugin.Cloudflare --source https://baget.frenchexdev.lab/v3/index.jsonAnd use it:
# config-homelab.yaml
plugins:
- FrenchExDev.HomeLab.Plugin.Cloudflare
dns:
provider: cloudflare
cloudflare:
zone_id: abc123def456789# config-homelab.yaml
plugins:
- FrenchExDev.HomeLab.Plugin.Cloudflare
dns:
provider: cloudflare
cloudflare:
zone_id: abc123def456789$ homelab secret set CLOUDFLARE_API_TOKEN
Enter value: ************
$ homelab dns add gitlab.frenchexdev.lab 1.2.3.4
✓ created Cloudflare A record gitlab.frenchexdev.lab → 1.2.3.4$ homelab secret set CLOUDFLARE_API_TOKEN
Enter value: ************
$ homelab dns add gitlab.frenchexdev.lab 1.2.3.4
✓ created Cloudflare A record gitlab.frenchexdev.lab → 1.2.3.4The plugin is in the dependency graph. HomeLab discovered it. Its IDnsProvider is registered. The user picked it via config. Everything just works.
What you get for the afternoon's work
- One plugin entry point class (~10 lines)
- One typed API client interface (~30 lines, the rest is generated)
- One
IDnsProviderimplementation (~80 lines) - One JSON manifest (~15 lines)
- One csproj (~25 lines)
- One test class (~50 lines)
That is the entire plugin: ~210 lines of code, zero of which are wiring (the source generators and DI handle it). Compare to forking HomeLab and adding the same capability in-tree, which would require touching the build, the composition root, the test suite, and the docs — and would be merged once, by one team, and never benefit anyone else.
What this gives you that bash doesn't
A bash script does not have plugins. Adding a new DNS provider to a bash homelab is a fork or a wrapper script that grows new flags every quarter.
A typed plugin SDK with [Injectable] registration, a JSON manifest, and a test harness gives you, for the same surface area:
- An afternoon project to add a new capability to HomeLab
- NuGet distribution so the plugin is shareable
- A test harness that builds a minimal HomeLab DI container
- Architecture compliance (the plugin uses
Result<T>,[Injectable], the event bus — same as the core) - Zero forking of HomeLab itself
The bargain pays back the first time you ship a plugin and someone else uses it without ever knowing your name.