Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

Getting Started

A public address for something that is only listening on localhost.

The problem is the one Shiny.Net.HttpServer solves for an embedded server, and the pieces are the same pieces: a pluggable ITunnelProvider, the Shiny relay at both ends, SSH remote forwarding, zero-account quick tunnels, and Azure Relay. What changes is who runs them — here it is the Aspire AppHost, on behalf of a resource in the app model.

GitHub GitHub stars for shinyorg/aspire
Downloads NuGet downloads for Shiny.Aspire.Hosting.Tunnel
Frameworks
.NET

All of these install into the AppHost project. Nothing is installed into the projects being published — a tunnelled service is not modified and never learns it is reachable from anywhere else.

Package Description
NuGet package Shiny.Aspire.Hosting.Tunnel The tunnel resource, the pluggable provider, and the Shiny relay hosted in the app model
NuGet package Shiny.Aspire.Hosting.Tunnel.Ssh Quick tunnels, SSH remote forwarding, and local forwards to services behind a bastion
NuGet package Shiny.Aspire.Hosting.Tunnel.AzureRelay Azure Relay Hybrid Connections
NuGet package Shiny.Aspire.Hosting.Tunnel.Cloudflare cloudflared as a container resource
NuGet package Shiny.Aspire.Hosting.Tunnel.Ngrok The ngrok agent as a container resource
  1. Install the package into your AppHost project

    NuGet package Shiny.Aspire.Hosting.Tunnel.Ssh
  2. Publish a resource

    var builder = DistributedApplication.CreateBuilder(args);
    var api = builder.AddProject<Projects.Api>("api");
    // A public HTTPS address. No account, nothing installed.
    api.WithQuickTunnel();
    builder.Build().Run();
  3. Read the address off the tunnel resource in the dashboard, or hand it to the service that needs it

    var tunnel = builder.AddQuickTunnel("public");
    builder.AddProject<Projects.Api>("api")
    .WithTunnel(tunnel)
    .WithTunnelUrl("PUBLIC_URL", tunnel);

A tunnel is a resource whose public URL is its connection string. That single decision is what makes the rest work without introducing new concepts:

  • WithReference(tunnel) injects it as ConnectionStrings__<name>
  • WithTunnelUrl("PUBLIC_URL", tunnel) names the variable yourself
  • both wait for the tunnel to actually open before the referencing resource starts

A tunnel opens when its target’s endpoints are allocated, which happens before the target process is launched. A service may therefore reference its own tunnel — which is the usual case, since a webhook receiver has to hand out its own address, and an OAuth client has to register its own redirect URI.

Method Package Address Needs
WithQuickTunnel() …​.Ssh Assigned, changes on reconnect Nothing at all
WithSshTunnel(host, …) …​.Ssh Yours An SSH server you can log into
WithShinyRelayTunnel(relay) core Yours The relay, hosted here or on a VPS
WithAzureRelayTunnel(cs) …​.AzureRelay Stable, yours An Azure Relay namespace
WithCloudflareTunnel() …​.Cloudflare Assigned, or your domain Docker; an account for named tunnels
WithNgrokTunnel(token) …​.Ngrok Assigned, or a reserved domain Docker; an ngrok account

The first four run inside the AppHost as managed code — no daemon, no binary to install, nothing to log into, and identical on every developer’s machine and in CI. The last two run the vendor’s agent in a container.

Every provider offers both. AddXTunnel(...) on the builder returns the tunnel, for when something needs to reference it; WithXTunnel(...) on a target resource creates and attaches it in one line and returns the target, for when nothing does.

// Attach in one line — the tunnel resource is named "{target}-tunnel"
api.WithQuickTunnel();
// Keep a handle, because something else needs the URL
var tunnel = builder.AddQuickTunnel("public");
api.WithTunnel(tunnel);
worker.WithTunnelUrl("API_PUBLIC_URL", tunnel);

Pass name: explicitly when one resource has more than one tunnel — both default to {target}-tunnel, and Aspire rejects duplicate resource names.

  • The target must be a cleartext endpoint. TLS terminates at the public end of the tunnel, so what arrives is plain HTTP. The http endpoint is chosen by default for exactly that reason; name another with endpointName only if it is also cleartext.
  • Tunnels are excluded from the manifest. They are a development-time affordance for reaching a machine that is not on the internet; a deployed app has a real address.
  • An assigned address changes on reconnect. An environment variable handed to a running process cannot be revised afterwards, so WithTunnelUrl resolves once and keeps that value. The dashboard shows the current one.
  • Anything reachable through a tunnel is reachable by anyone who has the URL. These hostnames are unguessable, not private. Put authentication in front of anything that matters.
claude plugin marketplace add shinyorg/skills
claude plugin install shiny-aspire@shiny
copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny-aspire@shiny
View shiny-aspire Plugin
  • .NET 10
  • .NET Aspire 13.1+
  • Docker (for the Cloudflare and ngrok packages only)