A config-driven local reverse proxy with a stable public HTTPS URL. Edit one JSON file, run one command, share the link.
Built on .NET Aspire, YARP, and Microsoft Dev Tunnels.
You need a public HTTPS URL that points at something running on your laptop — to receive a webhook, demo a prototype, or test a mobile app against a local API. ngrok works, but you pay for stable URLs and lose control over headers, CORS, and request shaping. This project is the same idea, free, and you keep full control: add CORS, inject auth headers or JSON fields, hot-reload routes from a config file.
git clone https://github.com/LorcanChinnock/devtunnel-proxy.git
cd devtunnel-proxy
dotnet run --project src/AppHostFirst run opens a browser for devtunnel sign-in. Then:
- Open the Aspire dashboard URL printed in the terminal.
- Click the
tunnelresource — copy itshttps://*.devtunnels.msURL. - Edit
src/Proxy/appsettings.json→ setClusters.default.Destinations.primary.Addressto your upstream. YARP hot-reloads — no restart needed. - Edit
src/AppHost/appsettings.json→ changeDevTunnel:Idto a unique slug (a–z, 0–9,-) so collaborators get distinct stable URLs.
That's it. Anything hitting the tunnel URL is forwarded to your configured destination.
| Version | |
|---|---|
| .NET SDK | 10.0+ |
devtunnel CLI |
latest (install) |
| Account | Microsoft or GitHub (for devtunnel sign-in) |
devtunnel install one-liners — macOS: brew install --cask devtunnel; Windows: winget install Microsoft.devtunnel; Linux: curl -sL https://aka.ms/DevTunnelCliInstall | bash.
Stripe, GitHub, Slack, etc. need a public HTTPS endpoint to send events. Point the proxy at your local handler, paste the tunnel URL into the provider's webhook config.
Now https://<your-slug>.devtunnels.ms/stripe/events reaches http://localhost:5050/stripe/events.
Phones, tablets, and other dev machines can't reach localhost:5000. Run the proxy, give them the tunnel URL — works over cellular, hotel Wi-Fi, anywhere.
dotnet run --project src/AppHost
# share the printed devtunnels.ms URLStable across restarts as long as DevTunnel:Id doesn't change.
Wrap a bare API with browser-friendly CORS without modifying the upstream:
"Cors": {
"Policies": {
"default": {
"AllowedOrigins": ["https://my-spa.example.com"],
"AllowedMethods": ["GET", "POST"],
"AllowedHeaders": ["*"],
"AllowCredentials": false
}
}
},
"ReverseProxy": {
"Routes": {
"default": {
"ClusterId": "default",
"CorsPolicy": "default",
"Match": { "Path": "{**catch-all}" }
}
}
}["*"] enables AllowAny*. Combining AllowedOrigins: ["*"] with AllowCredentials: true is invalid and throws at startup — list explicit origins instead.
Public-facing tunnel, secret-bearing upstream. Use route transforms:
"Routes": {
"default": {
"ClusterId": "default",
"Match": { "Path": "{**catch-all}" },
"Transforms": [
{ "RequestHeader": "X-Api-Key", "Set": "your-secret-here" },
{ "RequestHeader": "X-Trace", "Append": "proxy" }
]
}
}Set overwrites client-supplied values. Append adds without replacing.
For upstreams that authenticate via body fields, not headers:
"Routes": {
"default": {
"ClusterId": "default",
"Match": { "Path": "{**catch-all}" },
"Metadata": {
"InjectJsonField:AuthToken": "your-server-side-secret",
"InjectJsonField:Count": "42",
"InjectJsonField:Nested": "{\"k\":\"v\"}"
}
}
}Each value parses as JSON first — "42" becomes a number, "true" a boolean, "{...}" a nested object. Anything else is injected as a string. Skipped silently when the body isn't JSON, is empty, or isn't an object.
| File | Purpose |
|---|---|
src/AppHost/appsettings.json |
Tunnel ID, public/private toggle |
src/Proxy/appsettings.json |
Routes, clusters, CORS policies, transforms |
YARP routes/clusters fully follow the upstream schema — see YARP config files.
Hot reload: Routes and Clusters reload on save with no restart. Cors:Policies are read at startup — changes need an AppHost restart.
DevTunnel:AnonymousAccess: true (the default) makes the tunnel URL publicly reachable by anyone who knows it. Don't proxy anything with secrets, dev databases, or unauthenticated admin surfaces over an anonymous tunnel.
Set DevTunnel:AnonymousAccess: false in src/AppHost/appsettings.json for a private tunnel. Recipients then need a Microsoft/GitHub login the owner has authorised, or an X-Tunnel-Authorization token from devtunnel token. Note: private tunnels block cross-origin browser callers — fetch() from a deployed SPA on another origin can't complete the interactive sign-in.
To report a vulnerability privately, please open a GitHub security advisory rather than a public issue.
Aspire startup fails with Failed to create dev tunnel '<id>' and the inner error is An item with the same key has already been added. Key: host. The same string also appears if you run devtunnel show <id> directly.
This is a bug in the devtunnel CLI's tunnel-access-token cache: two cached entries collide on the scope key host, and every partial-ID lookup throws while deserializing the cache. Logging out (devtunnel user logout) does not clear it — the data lives in the OS credential store under a separate entry.
The AppHost runs a startup pre-flight check that detects this and throws with the same fix instructions. To clear the cache:
| OS | Command |
|---|---|
| macOS | security delete-generic-password -s tunnels -a "https://global.rel.tunnels.api.visualstudio.com/auth/tunnels" |
| Linux | Remove the libsecret item with label tunnels and account …/auth/tunnels (e.g. via secret-tool clear or Seahorse). |
| Windows | Open Credential Manager → Windows Credentials → remove the tunnels entry whose target is …/auth/tunnels. |
After clearing, re-run — the CLI repopulates the cache cleanly. Your user login (the auth/github or auth/microsoft entry) is untouched.
src/
├── AppHost/ .NET Aspire app host — wires the proxy to a Dev Tunnel
└── Proxy/ ASP.NET Core + YARP — routes/clusters live in appsettings.json
tests/
└── Proxy.Tests/ xUnit v3 integration + unit tests
Issues and pull requests welcome. PRs run a build + test gate via GitHub Actions and require one CODEOWNER review before merge.
MIT.