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 CloudflaredKit
No 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