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 (recommended)โ
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 publicAnyone 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โ
| Symptom | Likely cause | Fix |
|---|---|---|
502 Bad Gateway | Tunnel port protocol doesn't match your app | devtunnel port delete my-teams-app -p 3978, then devtunnel port create my-teams-app -p 3978 --protocol http |
| Teams can't reach bot / install fails | Tunnel not hosting, or --endpoint doesn't match current URL | Confirm devtunnel host is running; re-check the registered endpoint |
| Worked yesterday, not today | Hostname changed (ngrok) or host process stopped | Re-run the host command; re-register the endpoint if the hostname changed |
curl hangs or times out | Host process isn't running, or wrong port tunneled | Check the port your app actually logged on startup |
curl works, Teams doesn't | Transport is fine; likely an app-auth issue | See Authentication Troubleshooting |
Related pagesโ
- Running in Teams: TypeScript ยท C# ยท Python โ end-to-end registration and sideload flow
- Local Tunnels (CLI reference) โ the same setup via
teams app create/update - Teams Core Concepts โ where the tunnel fits among app registration and sideloading
- Azure Configuration โ pointing an Azure Bot resource's messaging endpoint at your tunnel