Skip to main content

Exposing Your Local App with a Tunnel

Teams, Agent 365, and other webhook callers deliver requests from their own servers, not a browser โ€” so your endpoint must be reachable on the public internet over HTTPS, even while developing on localhost. A tunnel gives your local server a public URL for exactly this: bots, message extensions, Agent 365 callbacks, and any other inbound webhook your app registers. Once you deploy to a real host, you don't need a tunnel.

Dev Tunnels is Microsoft's tunneling service, built into Visual Studio, VS Code, and the Microsoft 365 Agents Toolkit. A persistent tunnel keeps the same hostname across restarts, so you register your endpoint once.

Install:

# macOS / Linux
brew install devtunnel

# Windows (winget)
winget install Microsoft.devtunnel

Create a persistent, anonymous-access tunnel:

devtunnel user login
devtunnel create my-teams-app --allow-anonymous
devtunnel port create my-teams-app -p 3978
devtunnel host my-teams-app

Use the printed public URL (always HTTPS) as your --endpoint:

teams app create \
--name my-bot \
--endpoint https://my-teams-app-3978.usw2.devtunnels.ms/api/messages \
--env .env

Alternativesโ€‹

ngrok and Cloudflare Tunnel work the same way: start a tunnel, get a public HTTPS URL, use it as your --endpoint. Fine substitutes if you're already using one โ€” see their docs for setup.

Verifying the tunnel reaches your appโ€‹

curl -i https://<your-tunnel-host>/api/messages

Any response from your app โ€” even an error like 401 or 404 โ€” confirms the tunnel is forwarding correctly; move on and test from Teams. No response or a 502 means transport is broken โ€” see Troubleshooting.

Persistent vs. ephemeral URLsโ€‹

A Dev Tunnels persistent tunnel (devtunnel create <tunnel-id>) keeps its hostname across restarts, so you register the endpoint once. ngrok's free tier assigns a new hostname every run, so you must re-register (teams app update <id> --endpoint ...) each time โ€” and possibly reinstall the app in Teams if the client cached the old endpoint.

--allow-anonymous makes the tunnel public

Anyone with the URL can reach it โ€” required so Teams (not a Dev Tunnels client) can call your endpoint. It doesn't replace app-level auth: keep JWT/activity validation on, and delete the tunnel (devtunnel delete <tunnel-id>) when you're done.

Managing a persistent Dev Tunnelโ€‹

devtunnel host my-teams-app       # start (after the one-time create/port setup above)
# Ctrl+C to stop

devtunnel host my-teams-app # reuse later โ€” same command, same hostname
devtunnel delete my-teams-app # delete when no longer needed

Troubleshootingโ€‹

SymptomLikely causeFix
502 Bad GatewayTunnel port protocol doesn't match your appdevtunnel port delete my-teams-app -p 3978, then devtunnel port create my-teams-app -p 3978 --protocol http
Teams can't reach bot / install failsTunnel not hosting, or --endpoint doesn't match current URLConfirm devtunnel host is running; re-check the registered endpoint
Worked yesterday, not todayHostname changed (ngrok) or host process stoppedRe-run the host command; re-register the endpoint if the hostname changed
curl hangs or times outHost process isn't running, or wrong port tunneledCheck the port your app actually logged on startup
curl works, Teams doesn'tTransport is fine; likely an app-auth issueSee Authentication Troubleshooting