Featured image of post Use non-localhost endpoints for .NET Aspire resources

Use non-localhost endpoints for .NET Aspire resources

Learn how to use custom hosts instead of localhost for .NET Aspire resources during local development with or without a reverse proxy.

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

  1. 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.

  2. .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.

  3. Looking for a way to host a reverse proxy in .NET Aspire? Check out David Fowler’s YARP integration.

  4. Don’t forget to add any custom local-only host to your hosts file.

Licensed under CC BY 4.0
Ko-fi donations Buy me a coffee