Featured image of post Make containers aware of custom local domains on the host machine using .NET Aspire

Make containers aware of custom local domains on the host machine using .NET Aspire

Custom domains are useful for local development, but containers can't resolve them by default. Discover how to make containers recognize the host machine's custom hosts with .NET Aspire.

In my first job as a developer, we used custom hosts for local development with IIS. This allowed us to get a bit closer to the production configuration. We had to edit our C:\Windows\System32\drivers\etc\hosts file and add an entry for each host mapped to the IP address 127.0.0.1. Overall, this worked well.

Nowadays, the introduction of containers into the local development flow makes this practice more complicated. Containers do not share the same hosts file as the host machine and cannot resolve custom hosts. Most developers are therefore forced to use host.docker.internal for Docker or host.containers.internal for Podman to access services on the host machine.

In this article, we’ll enable containers to share the host machine’s hosts file and automate this process with .NET Aspire to simplify URL management when dealing with multiple services in local development.

Why use custom hosts for local development?

Using custom hosts for local development is a fairly common practice. Relying solely on localhost can cause some limitations, such as:

  • An excessive number of cookies leading to HTTP 400 Bad Request errors Request Header Or Cookie Too Large.
  • A poor development experience where you have to remember the ports used by each service.
  • Difficulty testing CORS configuration.
  • Inability to use subdomains if necessary.

It’s not uncommon to see developers create local domains ending with .local, .dev, .test, etc. Ideally, you should use the top-level domain (TLD) .localhost, which is reserved by the Internet Engineering Task Force (IETF). There’s no risk that this domain will ever be registered on the internet and made available for purchase, making it the safest choice.

Informing containers about custom hosts

To allow containers to resolve the host machine’s custom hosts, we need to read its hosts file and share the custom hosts pointing to 127.0.0.1 with the containers. We can do this using the --add-host argument of Docker or Podman. For example:

--add-host mycustomdomain.localhost:host-gateway

host-gateway is a special hostname that allows Docker or Podman to resolve the host machine’s internal IP address from within the container.

In this example, we’re redirecting mycustomdomain.localhost traffic from the container to the host machine.

Automating custom host sharing with .NET Aspire

We can write an extension method to automatically add the --add-host arguments to designated ContainerResource resources:

builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/samples", "aspnetapp")
    .WithLocalhostHostsFromHost();

internal static partial class ContainerResourceBuilderExtensions
{
    public static IResourceBuilder<ContainerResource> WithLocalhostHostsFromHost(this IResourceBuilder<ContainerResource> builder)
    {
        builder.ApplicationBuilder.Services.TryAddLifecycleHook<LocalhostHostsFromHostLifecycleHook>();
        return builder.WithAnnotation<LocalhostHostsFromHostAnnotation>(ResourceAnnotationMutationBehavior.Replace);
    }

    private sealed class LocalhostHostsFromHostAnnotation : IResourceAnnotation;

    private sealed partial class LocalhostHostsFromHostLifecycleHook : IDistributedApplicationLifecycleHook
    {
        public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
        {
            var hostsFilePath = OperatingSystem.IsWindows()
                ? @"C:\Windows\System32\drivers\etc\hosts"
                : "/etc/hosts";

            var customLocalhostHosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            try
            {
                await foreach (var line in File.ReadLinesAsync(hostsFilePath, cancellationToken))
                {
                    if (LocalhostHostFileLineRegex().Match(line) is { Success: true } match)
                    {
                        customLocalhostHosts.Add(match.Groups["hostname"].Value);
                    }
                }
            }
            catch (OperationCanceledException)
            {
                // Application is shutting down
                return;
            }
            catch (IOException ex)
            {
                throw new InvalidOperationException("An error occurred while reading the hosts file.", ex);
            }

            // Remove non-custom hostnames that may interfere with the container's network resolution
            customLocalhostHosts.Remove("localhost");
            customLocalhostHosts.Remove("host.docker.internal");
            customLocalhostHosts.Remove("host.containers.internal");
            customLocalhostHosts.Remove("gateway.docker.internal");
            customLocalhostHosts.Remove("kubernetes.docker.internal");

            var addHostsAnnotation = new ContainerRuntimeArgsCallbackAnnotation(args =>
            {
                foreach (var hostname in customLocalhostHosts)
                {
                    args.Add("--add-host");
                    args.Add($"{hostname}:host-gateway");
                }
            });

            foreach (var containerResource in appModel.Resources.OfType<ContainerResource>())
            {
                if (containerResource.Annotations.OfType<LocalhostHostsFromHostAnnotation>().Any())
                {
                    containerResource.Annotations.Add(addHostsAnnotation);
                }
            }
        }
        
        [GeneratedRegex(@"^127\.0\.0\.1\s+(?<hostname>[a-z0-9\-\.]+)$", RegexOptions.IgnoreCase)]
        private static partial Regex LocalhostHostFileLineRegex();
    }
}

Given this Windows hosts file:

#   127.0.0.1       localhost
#   ::1             localhost

127.0.0.1 api.myapp.localhost
127.0.0.1 app.myapp.localhost
127.0.0.1 analytics.myapp.localhost

My mcr.microsoft.com/dotnet/samples:aspnetapp container will be started with the --add-host arguments:

The container is now aware of our custom hosts and will redirect the traffic to the host

Conclusion

In this post, we learned how to make containers aware of custom local domains on the host machine using .NET Aspire. If you combine this feature with the ability to automatically trust self-signed certificates in containers, you will have a container-based development experience that closely resembles running the services directly on your machine without the extra cost of running an IDE.

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