Providers
Every provider attaches to a resource the same way and surfaces its address the same way. What differs is where the address comes from and what you have to have first.
Quick tunnels
Section titled “Quick tunnels”The zero-ceremony version: no account, nothing installed, no infrastructure. The AppHost opens an outbound SSH connection to a public endpoint that hands back an address.
api.WithQuickTunnel(); // pinggy.io, the defaultapi.WithQuickTunnel(QuickTunnelHost.Sish); // tuns.shapi.WithQuickTunnel(subdomain: "<pinggy-access-token>"); // lifts the 60-minute anonymous capapi.WithQuickTunnel(configure: o => o.AutoReconnect = false);QuickTunnelHost picks the endpoint. The presets — host, port, username convention, and the regex
that reads the assigned address out of the session banner — come from Shiny.Net.HttpServer.Ssh, so
a tunnel is described identically here and in a MAUI app.
| Host | Notes |
|---|---|
Pinggy |
The default. No signup, and it reports the address it assigns in a form that can actually be read back. Anonymous tunnels are capped at 60 minutes; pass an access token as subdomain to lift that |
Sish |
The public tuns.sh instance, run by pico.sh. Needs a registered key — point PrivateKeyPath at the one you enrolled, or at your own sish deployment |
Serveo |
The longest-running of them, and frequently unreachable for days at a time. Do not build a demo on it |
LocalhostRun |
Forwards fine but never confirms the session request that carries the URL, so the address cannot be read. Usable only with an account whose custom domain you set as PublicUrl |
ssh -R in library form, against a host you can log into. The AppHost dials out and asks the server
to forward a port back down the connection, pointed straight at the port Aspire allocated for the
target.
api.WithSshTunnel("tunnel.example.com", ssh =>{ ssh.Username = "deploy"; ssh.PrivateKeyPath = "/home/me/.ssh/id_ed25519"; ssh.RemoteBindAddress = "0.0.0.0"; // needs GatewayPorts in the server's sshd config ssh.RemotePort = 8080; // 0 asks the server to allocate one ssh.PublicUrl = "https://api.example.com"; // where it really answers, if a proxy fronts it ssh.HostKeyFingerprints.Add("SHA256:47DEQpj8HBSa+…");});Nothing has to connect in, so this works from behind NAT, from a CI runner, and from a corporate network that allows nothing but outbound 443.
Options are SshTunnelOptions, the same type
Shiny.Net.HttpServer.Ssh uses. Reconnect behaviour, keep-alives, ephemeral keys
and host-key pinning all work as documented there.
Where the address comes from
Section titled “Where the address comes from”In order of preference:
PublicUrl, if you set it — the only honest answer when a reverse proxy terminates TLS in front of the forwarded port.- The session banner, when
CaptureUrlFromSessionis on. Hosted endpoints choose the address themselves and print it on the session channel; there is no other way to learn it. http://{host}:{remotePort}— derived, and correct only for a direct forward.
Azure Relay
Section titled “Azure Relay”Hybrid Connections: Azure holds the public endpoint, the AppHost connects outbound to it, and requests are relayed back. The address is stable and yours, which is what separates this from a quick tunnel — a webhook registered against it survives restarts, network changes and reconnects.
var cs = builder.AddParameter("relay-cs", secret: true);
api.WithAzureRelayTunnel(cs, hybridConnectionName: "api");The hybrid connection may instead be named by the connection string’s EntityPath. Anything else on
AzureRelayOptions — the mode, keep-alive interval, SAS refresh callback, authorization hook — is
reachable through configure:
api.WithAzureRelayTunnel(cs, "api", configure: o =>{ o.Mode = AzureRelayMode.Http; o.KeepAliveInterval = TimeSpan.FromSeconds(30);});A plain string overload exists for a connection string that is not a secret, and
AddAzureRelayTunnel gives you the tunnel resource when something needs to reference it.
Cloudflare
Section titled “Cloudflare”cloudflared runs as an ordinary container resource: Aspire starts it, stops it, and shows its logs
like anything else.
// A throwaway trycloudflare.com address, no accountapi.WithCloudflareTunnel();A named tunnel runs against a token from your own Cloudflare account and answers on a host name you control. Ingress is configured in Cloudflare rather than here, so the address is stated rather than discovered:
builder.AddCloudflareTunnel("public") .WithNamedTunnel(builder.AddParameter("cf-token", secret: true), "https://api.example.com");To keep the agent and point it at an endpoint yourself:
builder.AddCloudflareTunnel("public") .WithOrigin(api.GetEndpoint("http"));The ngrok agent, also as a container resource. An auth token is required — ngrok will not start an anonymous tunnel.
var token = builder.AddParameter("ngrok-token", secret: true);
api.WithNgrokTunnel(token);A domain reserved in your account, which is what makes a registered webhook survive a restart:
builder.AddNgrokTunnel("public", token) .WithOrigin(api.GetEndpoint("http")) .WithDomain("api.ngrok.app");The agent is started with --log stdout --log-format json, because its default interactive terminal
UI prints nothing useful to a container log.
How the container agents report their address
Section titled “How the container agents report their address”Both pick a hostname per run and announce it in their own output. There is no other channel that
reports it and no way to know it in advance, so the address is read back out of the resource’s log
stream and published from there — into the dashboard, and into the connection string that
WithReference and WithTunnelUrl resolve.


