A .NET library that manages the cloudflared binary and Cloudflare Tunnel lifecycle programmatically.
Inspired by FluffySpoon.Ngrok.
- TryCloudflare mode — No account or pre-configuration needed. Issues a random
https://xxxxx.trycloudflare.comURL at startup. - Permanent tunnel mode — Specify a Cloudflare Zero Trust tunnel token to run with a fixed URL.
- Automatic binary management — Downloads and caches the
cloudflaredbinary from GitHub Releases. - DI support — Works with
Microsoft.Extensions.DependencyInjection(console apps, generic host, etc.). - Lifetime hooks —
ICloudflaredLifetimeHookcallbacks fired on tunnel creation and destruction. - Unexpected-exit notification —
ICloudflaredService.TunnelExitedUnexpectedlyevent fires whencloudflaredexits withoutStopAsyncbeing called, so callers can implement their own restart strategy.
| OS | Architecture |
|---|---|
| Linux | x64 |
| Linux | x86 (32-bit) |
| Linux | arm64 |
| Linux | arm (32-bit, armhf / armel) |
| macOS | x64 |
| macOS | arm64 (Apple Silicon) |
| Windows | x64 |
| Windows | x86 (32-bit) |
dotnet add package CloudflaredKitNo account or pre-configuration required. Just specify the local port and a temporary public URL is issued automatically.
using CloudflaredKit;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddTryCloudflare(options =>
{
options.LocalPort = 5000;
});
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<ICloudflaredService>();
// Downloads cloudflared on first run, then starts the tunnel.
var tunnel = await service.StartAsync();
Console.WriteLine($"Public URL: {tunnel.PublicUrl}");
// => Public URL: https://abc-def-123.trycloudflare.com
// ... application logic ...
await service.StopAsync();Create a tunnel in the Cloudflare Zero Trust dashboard and pass the generated token.
services.AddTryCloudflare(options =>
{
options.TunnelToken = "your-cloudflare-tunnel-token";
});In permanent tunnel mode, the public URL is managed in the Cloudflare dashboard,
so TunnelInfo.PublicUrl is null.
{
"Cloudflare": {
"LocalPort": 5000
}
}builder.Services.AddTryCloudflare(builder.Configuration.GetSection("Cloudflare"));Implement ICloudflaredLifetimeHook to run custom logic whenever a tunnel is created or destroyed.
This is useful when something outside the tunnel startup flow needs to react to the public URL —
for example, registering it with a remote service.
public class MyHook : ICloudflaredLifetimeHook
{
public async Task OnCreatedAsync(TunnelInfo tunnel, CancellationToken cancellationToken)
{
// Called after the tunnel is up. tunnel.PublicUrl contains the public URL.
await RegisterSomewhereAsync(tunnel.PublicUrl, cancellationToken);
}
public async Task OnDestroyedAsync(TunnelInfo tunnel, CancellationToken cancellationToken)
{
// Called after the tunnel is stopped.
await UnregisterSomewhereAsync(tunnel.PublicUrl, cancellationToken);
}
}services.AddTryCloudflare(options => options.LocalPort = 5000);
services.AddCloudflaredLifetimeHook<MyHook>();Hooks are optional. If you only need the URL at the call site, use the return value of StartAsync instead.
When cloudflared exits without StopAsync being called (e.g. a crash or OS signal),
ICloudflaredService raises the TunnelExitedUnexpectedly event with the process exit code.
The library intentionally does not restart automatically — the caller decides whether and how to retry.
var service = provider.GetRequiredService<ICloudflaredService>();
// Subscribe before starting the tunnel.
service.TunnelExitedUnexpectedly += exitCode =>
{
Console.Error.WriteLine($"cloudflared exited unexpectedly (code={exitCode})");
// Decide what to do: log, alert, attempt restart, etc.
};
var tunnel = await service.StartAsync();Restart example with exponential back-off:
service.TunnelExitedUnexpectedly += exitCode =>
{
_ = RestartLoopAsync(service, CancellationToken.None);
};
static async Task RestartLoopAsync(ICloudflaredService service, CancellationToken ct)
{
var delay = TimeSpan.FromSeconds(5);
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(delay, ct);
var tunnel = await service.StartAsync(ct);
Console.WriteLine($"Tunnel restarted: {tunnel.PublicUrl}");
return;
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
Console.Error.WriteLine($"Restart failed: {ex.Message}");
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 60));
}
}
}Note —
TunnelExitedUnexpectedlyis raised from a thread-pool thread (the OS process-exit callback). Keep the handler short and kick off any async work with_ = SomeAsync()as shown above. Always unsubscribe the event before callingStopAsyncto avoid receiving a stale notification.
StartAsync already blocks until the tunnel is up and returns TunnelInfo directly,
so in most cases you do not need WaitUntilReadyAsync.
// Normal usage — URL is available immediately after await.
var tunnel = await service.StartAsync();
Console.WriteLine(tunnel.PublicUrl);WaitUntilReadyAsync is only needed when StartAsync is called without being awaited
(fire-and-forget), and a separate part of the code needs to wait for readiness.
// Fire-and-forget start (unusual).
_ = service.StartAsync();
// Somewhere else, wait until the tunnel is ready.
await service.WaitUntilReadyAsync();
Console.WriteLine(service.ActiveTunnel?.PublicUrl);Skip the automatic download and point to an existing binary.
services.AddTryCloudflare(options =>
{
options.LocalPort = 5000;
options.CloudflaredPath = "/usr/local/bin/cloudflared";
});By default, TryCloudflare forwards to http://localhost:{LocalPort}.
If your local server is bound to IPv4 loopback only, or localhost resolves to an address your server is not listening on, specify 127.0.0.1 explicitly.
services.AddTryCloudflare(options =>
{
options.LocalPort = 5000;
options.LocalHostName = "127.0.0.1";
});| Property | Type | Default | Description |
|---|---|---|---|
LocalPort |
int |
80 |
Local port to expose (TryCloudflare mode only) |
LocalHostName |
string |
localhost |
Local host name or IP address to expose (TryCloudflare mode only) |
TunnelToken |
string? |
null |
Cloudflare tunnel token. When null, TryCloudflare mode is used |
CloudflaredPath |
string? |
null |
Path to an existing cloudflared binary. When null, auto-downloaded |
CacheDirectory |
string? |
null |
Directory used to cache the downloaded cloudflared binary. When null, the platform default cache location below is used |
| OS | Path |
|---|---|
| Windows | %LOCALAPPDATA%\TryCloudflare\cloudflared.exe |
| Linux | ~/.local/share/TryCloudflare/cloudflared |
| macOS | ~/Library/Caches/TryCloudflare/cloudflared |
TryCloudflare E2E tests can be run locally without a Cloudflare account. They download cloudflared, open a temporary public tunnel, and verify that traffic reaches a local test server.
$env:CLOUDFLARED_E2E = "1"
dotnet test .\src\CloudflaredKit.Tests\CloudflaredKit.Tests.csproj -c Release --filter "FullyQualifiedName~CloudflaredE2ETests"Permanent tunnel E2E tests are skipped unless CLOUDFLARED_PERMANENT_E2E=1 and CLOUDFLARE_TUNNEL_TOKEN are also set.
MIT