In .NET Aspire 9 and earlier versions, resource endpoints can only use localhost
in run mode. This can cause issues if you use custom domains for local development. This feature has been requested in GitHub issue #5508 and #4319, but it’s currently unknown whether it will be implemented.
After removing the limitations of HTTPS and the use of custom domains with containers in local development, we will now tackle this missing piece of the puzzle. While awaiting an official solution, here is a workaround to use and display non-localhost endpoints in the .NET Aspire dashboard.
Quick analysis of the resource endpoint system
.NET Aspire provides default extension methods WithEndpoint
, WithHttpEndpoint
, and WithHttpsEndpoint
to declare endpoints for resources, which can be referenced by other resources. This results in the creation of an EndpointAnnotation
, which contains networking information such as the TCP or UDP protocol, port, URI scheme, etc.
Since version 9, this annotation also has a TargetHost
property, which is initialized to localhost
by default. One might think that modifying this property to use another hostname would solve the problem, but that’s not the case. Currently, this property is only used when ASP.NET Core application URLs contain wildcards (also known as catch-all in ASP.NET Core route templates), such as http://*:5031
. Ultimately, the dashboard will still display localhost
.
Each time a resource of type Project, Executable, or Container changes—such as transitioning from a Waiting
to Starting
status—the resource’s snapshot is updated, and the full URLs are recalculated from the original endpoint annotations. In theory, we cannot use the ResourceNotificationService to modify the URLs, as they will be overwritten. Unless…
await notificationService.PublishUpdateAsync(evt.Resource, snapshot => snapshot with
{
// This will likely be overwritten before you can see the change in the dashboard
Urls = [.. snapshot.Urls, new UrlSnapshot("custom", "https://myapp.localhost:8080", IsInternal: false)]
});
Declaring non-localhost, custom endpoints
Non-localhost endpoints are often used in the context of a reverse proxy, so we will introduce new types and methods that refer to this in their names:
builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/samples", "aspnetapp")
.WithHttpEndpoint(name: "local", targetPort: 8080, port: 8080)
// This is the new extension method we'll implement
.WithReverseProxyEndpoint(name: "custom", url: "https://myapp.localhost:8080");
Here is the beginning of the implementation, which includes a new ReverseProxyEndpointAnnotation
and an extension method WithReverseProxyEndpoint
that adds the annotation to a resource and registers a distributed application lifecycle hook to apply the snapshot changes:
public sealed class ReverseProxyEndpointAnnotation : IResourceAnnotation
{
public ReverseProxyEndpointAnnotation([EndpointName] string name, string url)
{
ArgumentNullException.ThrowIfNull(name);
// Instantiating an EndpointAnnotation triggers the built-in, internal validation of the endpoint name.
_ = new EndpointAnnotation(ProtocolType.Tcp, name: name);
if (!Uri.TryCreate(url, UriKind.Absolute, out _))
{
throw new ArgumentException($"'{url}' is not an absolute URL.", nameof(url));
}
this.Name = name;
this.Url = url;
}
public string Name { get; }
public string Url { get; }
}
public static class ReverseProxyExtensions
{
public static IResourceBuilder<T> WithReverseProxyEndpoint<T>(this IResourceBuilder<T> builder, string name, string url)
where T : IResource
{
// Best effort to prevent duplicate endpoint names
if (builder.Resource.Annotations.OfType<EndpointAnnotation>().Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)))
{
throw new DistributedApplicationException($"Endpoint with name '{name}' already exists.");
}
if (builder.Resource.Annotations.OfType<ReverseProxyEndpointAnnotation>().Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)))
{
throw new DistributedApplicationException($"Endpoint with name '{name}' already exists.");
}
builder.ApplicationBuilder.Services.TryAddLifecycleHook<ReverseProxyLifecycleHook>();
return builder.WithAnnotation(new ReverseProxyEndpointAnnotation(name, url));
}
private sealed class ReverseProxyLifecycleHook(ResourceNotificationService notificationService, ILogger<ReverseProxyLifecycleHook> logger) : IDistributedApplicationLifecycleHook, IAsyncDisposable
{
// We'll implement this later
}
}
Hacking around the resource notification service and resource snapshots
One of the very practical features of the ResourceNotificationService
is to watch the status changes of any resource orchestrated by .NET Aspire, including the dashboard itself!
The idea is to continuously observe the changes of the resources that have at least one ReverseProxyEndpointAnnotation
and insert our own URLs as soon as we detect that they have been replaced. There’s a small subtlety, however. Adding one or more URLs to a resource as demonstrated in the previous code will trigger a snapshot change, which will be observed by the notification service. We must therefore avoid infinite loops.
Without further ado, here is the implementation of the distributed application lifecycle hook ReverseProxyLifecycleHook
declared earlier:
private sealed class ReverseProxyLifecycleHook(ResourceNotificationService notificationService, ILogger<ReverseProxyLifecycleHook> logger) : IDistributedApplicationLifecycleHook, IAsyncDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task? _task;
public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
{
this._task = this.EnsureUrlsAsync(this._cts.Token);
return Task.CompletedTask;
}
private async Task EnsureUrlsAsync(CancellationToken cancellationToken)
{
await foreach (var evt in notificationService.WatchAsync(cancellationToken))
{
if (evt.Snapshot.State != KnownResourceStates.Running)
{
// By default, .NET Aspire only displays endpoints for running resources.
continue;
}
var urlsToAdd = ImmutableArray.CreateBuilder<UrlSnapshot>();
foreach (var endpoint in evt.Resource.Annotations.OfType<ReverseProxyEndpointAnnotation>())
{
var urlAlreadyAdded = evt.Snapshot.Urls.Any(x => string.Equals(x.Name, endpoint.Name, StringComparison.OrdinalIgnoreCase));
if (!urlAlreadyAdded)
{
urlsToAdd.Add(new UrlSnapshot(endpoint.Name, endpoint.Url, IsInternal: false));
}
}
if (urlsToAdd.Count > 0)
{
await notificationService.PublishUpdateAsync(evt.Resource, snapshot => snapshot with
{
Urls = snapshot.Urls.AddRange(urlsToAdd)
});
}
}
}
public async ValueTask DisposeAsync()
{
await this._cts.CancelAsync();
if (this._task != null)
{
try
{
await this._task;
}
catch (OperationCanceledException)
{
// Application is shutting down
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while adding reverse proxy endpoints.");
}
}
this._cts.Dispose();
}
}
Additional considerations
It is not necessary to use this approach with custom resources, where you have complete control over the URLs at any time from the moment the resource is created by specifying its initial state.
.NET Aspire uses a specific order when displaying URLs in the dashboard. Those using HTTPS are prioritized, and then they are sorted by the endpoint name.
Looking for a way to host a reverse proxy in .NET Aspire? Check out David Fowler’s YARP integration.
Don’t forget to add any custom local-only host to your
hosts
file.