diff --git a/.gitignore b/.gitignore
index 7724559c..c6f5531c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -431,3 +431,5 @@ FodyWeavers.xsd
**/*/azure.yaml
**/*/next-steps.md
+# Nginx config files generated by WithContainerProxy
+/samples/Metrics/MetricsApp.AppHost/*-proxy.nginx.conf
diff --git a/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyAnnotation.cs b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyAnnotation.cs
new file mode 100644
index 00000000..376bf9b3
--- /dev/null
+++ b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyAnnotation.cs
@@ -0,0 +1,13 @@
+namespace MetricsApp.AppHost.ContainerProxy;
+
+internal sealed class ContainerProxyAnnotation : IResourceAnnotation
+{
+ public ContainerProxyAnnotation(IResourceWithEndpoints proxiedResource)
+ {
+ ProxiedResource = proxiedResource;
+ }
+
+ public string? FilePath { get; set; }
+
+ public IResourceWithEndpoints ProxiedResource { get; }
+}
diff --git a/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceExtensions.cs b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceExtensions.cs
new file mode 100644
index 00000000..596158ff
--- /dev/null
+++ b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceExtensions.cs
@@ -0,0 +1,31 @@
+using Aspire.Hosting.Lifecycle;
+
+namespace MetricsApp.AppHost.ContainerProxy;
+
+public static class ContainerProxyResourceExtensions
+{
+ ///
+ /// Adds nginx container with name of resource, which will proxy requests from other s to identified by .
+ ///
+ ///
+ /// The .
+ public static IResourceBuilder WithContainerProxy(this IResourceBuilder projectResourceBuilder)
+ {
+ projectResourceBuilder.ApplicationBuilder.Services.TryAddLifecycleHook();
+
+ projectResourceBuilder.ApplicationBuilder.AddContainer(projectResourceBuilder.Resource.Name + "-proxy", "nginx")
+ .WithAnnotation(new ContainerProxyAnnotation(projectResourceBuilder.Resource))
+ .WithContainerRuntimeArgs(context =>
+ {
+ context.Args.Add("--network-alias");
+ context.Args.Add(projectResourceBuilder.Resource.Name);
+
+ context.Args.Add("--hostname");
+ context.Args.Add(projectResourceBuilder.Resource.Name);
+ })
+ .WithHttpEndpoint(targetPort: 80, name: "http")
+ .ExcludeFromManifest();
+
+ return projectResourceBuilder;
+ }
+}
diff --git a/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceLifecycleHook.cs b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceLifecycleHook.cs
new file mode 100644
index 00000000..6993a6c4
--- /dev/null
+++ b/samples/Metrics/MetricsApp.AppHost/ContainerProxy/ContainerProxyResourceLifecycleHook.cs
@@ -0,0 +1,104 @@
+using Aspire.Hosting.Lifecycle;
+using CliWrap;
+using CliWrap.EventStream;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MetricsApp.AppHost.ContainerProxy
+{
+ internal sealed class ContainerProxyResourceLifecycleHook : IDistributedApplicationLifecycleHook, IAsyncDisposable
+ {
+ private const string NginxConfigTemplate = @"
+events {}
+
+http {
+ server {
+ listen 80;
+
+ location / {
+ proxy_pass http://{host}:{port};
+ }
+ }
+}
+";
+
+ private readonly ILogger _logger;
+ private readonly IHostEnvironment _hostEnvironment;
+ private readonly string _networkNamePrefix;
+ private readonly string _networkName;
+
+ public ContainerProxyResourceLifecycleHook(
+ ILogger logger,
+ IHostEnvironment hostEnvironment)
+ {
+ _logger = logger;
+ _hostEnvironment = hostEnvironment;
+
+ _networkNamePrefix = $"{hostEnvironment.ApplicationName}_Proxy_";
+ _networkName = hostEnvironment.ApplicationName + Guid.NewGuid().ToString().Substring(0, 6);
+ }
+
+ public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
+ {
+ await CleanupNetworks(CancellationToken.None).ConfigureAwait(false);
+
+ _logger.LogInformation("Creating docker network {Network}.", _networkName);
+ await Cli.Wrap("docker").WithArguments($"network create {_networkName}").ExecuteAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var containerResource in appModel.Resources.OfType())
+ {
+ var proxyAnnotation = containerResource.Annotations.OfType().SingleOrDefault();
+ if (proxyAnnotation != null)
+ {
+ var confFilePath = Path.Combine(_hostEnvironment.ContentRootPath, $"{containerResource.Name}.nginx.conf");
+ proxyAnnotation.FilePath = confFilePath;
+ containerResource.Annotations.Add(new ContainerMountAnnotation(confFilePath, "/etc/nginx/nginx.conf", ContainerMountType.BindMount, true));
+ }
+
+ containerResource.Annotations.Add(new ContainerRuntimeArgsCallbackAnnotation(context =>
+ {
+ context.Add("--network");
+ context.Add(_networkName);
+ }));
+ }
+ }
+
+ public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
+ {
+ foreach (var containerResource in appModel.Resources.OfType())
+ {
+ var proxyAnnotation = containerResource.Annotations.OfType().SingleOrDefault();
+ if (proxyAnnotation != null && proxyAnnotation.FilePath != null)
+ {
+ var endpoint = proxyAnnotation.ProxiedResource.GetEndpoint("http");
+ var configString = NginxConfigTemplate
+ .Replace("{host}", "host.docker.internal")
+ .Replace("{port}", endpoint.Port.ToString());
+ File.WriteAllText(proxyAnnotation.FilePath, configString);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await CleanupNetworks(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private async Task CleanupNetworks(CancellationToken cancellationToken)
+ {
+ var oldNetworks = Cli.Wrap("docker")
+ .WithArguments("network ls --format '{{.Name}}'")
+ .ListenAsync(cancellationToken)
+ .ToBlockingEnumerable(cancellationToken)
+ .OfType()
+ .Where(e => e.Text.StartsWith(_networkNamePrefix));
+ foreach (var oldNetwork in oldNetworks)
+ {
+ _logger.LogInformation("Deleting docker network {Network}.", oldNetwork);
+ await Cli.Wrap("docker").WithArguments($"network rm {oldNetwork}").ExecuteAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj b/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj
index 72e6ea71..3f3032f8 100644
--- a/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj
+++ b/samples/Metrics/MetricsApp.AppHost/MetricsApp.AppHost.csproj
@@ -10,6 +10,7 @@
+
diff --git a/samples/Metrics/MetricsApp.AppHost/Program.cs b/samples/Metrics/MetricsApp.AppHost/Program.cs
index 947bb53a..8888512c 100644
--- a/samples/Metrics/MetricsApp.AppHost/Program.cs
+++ b/samples/Metrics/MetricsApp.AppHost/Program.cs
@@ -1,4 +1,6 @@
-var builder = DistributedApplication.CreateBuilder(args);
+using MetricsApp.AppHost.ContainerProxy;
+
+var builder = DistributedApplication.CreateBuilder(args);
var grafana = builder.AddContainer("grafana", "grafana/grafana")
.WithBindMount("../grafana/config", "/etc/grafana", isReadOnly: true)
@@ -6,7 +8,8 @@
.WithHttpEndpoint(targetPort: 3000, name: "http");
builder.AddProject("app")
- .WithEnvironment("GRAFANA_URL", grafana.GetEndpoint("http"));
+ .WithEnvironment("GRAFANA_URL", grafana.GetEndpoint("http"))
+ .WithContainerProxy();
builder.AddContainer("prometheus", "prom/prometheus")
.WithBindMount("../prometheus", "/etc/prometheus", isReadOnly: true)
diff --git a/samples/Metrics/prometheus/prometheus.yml b/samples/Metrics/prometheus/prometheus.yml
index 89b3561a..5b9339ed 100644
--- a/samples/Metrics/prometheus/prometheus.yml
+++ b/samples/Metrics/prometheus/prometheus.yml
@@ -1,7 +1,7 @@
global:
- scrape_interval: 1s # makes for a good demo
+ scrape_interval: 5s # makes for a good demo
scrape_configs:
- job_name: 'metricsapp'
static_configs:
- - targets: ['host.docker.internal:5048'] # hard-coded port matches launchSettings.json
+ - targets: ['app']
\ No newline at end of file