Blog

Building a Microsoft Teams Bot with Private Networking in Azure (Terraform-based)

Victor de Baare

Victor de Baare

June 15, 2026
9 minutes

This article is part of the XPRT. Magazine #21


Microsoft Teams bots often start life as a small integration: a public endpoint, a shared secret, and you are done. In enterprise environments that convenience can become a problem. You may have requirements around network isolation, identity usage, and auditability, and suddenly “just expose it to the internet” is no longer acceptable.

This article walks through a private-first architecture for a Teams bot on Azure. The bot runtime runs on Azure Container Apps, inbound traffic is centralized through Application Gateway (WAF v2), outbound traffic is controlled by Azure Firewall, secrets live in Azure Key Vault, and Azure OpenAI is reached privately with public network access disabled. The entire platform is deployed with Terraform and the bot itself uses Semantic Kernel so one bot can support multiple commands without creating “bot sprawl”.

Why private networking matters for a Teams bot

A Teams bot may look like a SaaS-style extension, but from a platform perspective it is still a backend workload. It accepts inbound traffic, calls other services, processes data that may be sensitive, and may need credentials or tokens to do its job.

Private networking helps in a few practical ways. It reduces the attack surface by avoiding direct public exposure of the runtime. It centralizes traffic inspection and routing, so you have a single place to apply policies. It supports compliance efforts by complementing identity-based controls. It also makes connectivity more predictable, because the required endpoints become explicit and documented instead of “whatever the code can reach”.

A high-level view of the architecture

The request path starts in Microsoft Teams and goes through Azure Bot Service. Azure Bot Service is configured with a user-assigned managed identity and points its messaging endpoint to Application Gateway. Application Gateway is the only public entry point; it terminates TLS and forwards traffic to the bot running in Azure Container Apps.

When the bot needs to call external services, it does so through Azure Firewall. This is where outbound control lives: you explicitly allow the bot to reach the endpoints it needs for Bot Framework authentication and message delivery, while avoiding accidental or undesired internet access.

For AI responses, the bot calls Azure OpenAI. In this design, Azure OpenAI has public network access disabled and is reached privately. Secrets and configuration are stored in Azure Key Vault, and services authenticate to Azure resources using managed identities rather than stored secrets.


Identity first: user-assigned managed identities

The backbone of the setup is a user-assigned managed identity that you can attach to multiple services. This keeps the trust model simple. Instead of distributing secrets across pipelines and app settings, Azure issues tokens at runtime, and Azure RBAC controls what the bot can access.

In Terraform, you typically create the identity and grant it the minimum permissions it needs. For example, if the bot reads secrets from Key Vault, it can be assigned the Key Vault Secrets User role on that vault.

Azure Bot Service supports using a user-assigned managed identity instead of an app registration with a client secret. That choice matters because it removes secret distribution from your deployment

data "azurerm_client_config" "current" {}

resource "azurerm_key_vault" "main" {
  name                = "kv-bot-nprd-001"
  location            = "westeurope"
  resource_group_name = "rg-bot-nprd-001"
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name = "standard"
  enable_rbac_authorization = true
  purge_protection_enabled   = true
  soft_delete_retention_days = 7
}

resource "azurerm_user_assigned_identity" "identity" {
  name                = "uai-bot-nprd-001"
  resource_group_name = "rg-bot-nprd-001"
  location            = "westeurope"
}

resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.identity.principal_id
}

resource "azurerm_bot_service_azure_bot" "main" {
  name                    = "bot-command-nprd-001"
  location                = "global"
  microsoft_app_type      = "UserAssignedMSI"
  microsoft_app_id        = azurerm_user_assigned_identity.identity.client_id
  microsoft_app_msi_id    = azurerm_user_assigned_identity.identity.id
  microsoft_app_tenant_id = data.azurerm_client_config.current.tenant_id
  endpoint                = "https://appgw-commandbot-001-frontend.commandbot.azurewebsites.net/api/messages"
}

The important networking detail is the endpoint. The bot’s endpoint should point at Application Gateway http listener, not directly at the Container App. That keeps the public surface area small and lets the gateway enforce your ingress controls. The container app will only be reachable from the gateway, and the gateway is the only component with a public IP.

Running the bot on Azure Container Apps

To run the bot in Azure Container Apps, we need a clean way to pull images and load configuration. A common pattern is to grant Azure Container Apps a user-assigned identity that can both pull from Azure Container Registry and read from Key Vault, so the runtime never needs static credentials.

For the bot to validate incoming tokens correctly, the runtime also needs a few configuration values. In an ASP.NET app these are typically expressed as appsettings keys, but in Container Apps you set them as environment variables using double underscores (__). The values below tie token validation back to the bot’s user-assigned managed identity and tenant, which is what you want when you run the Bot Service in UserAssignedMSI mode.


resource "azurerm_container_app" "support_bot" {
  name                         = "ca-commandbot-nprd-001"
  resource_group_name          = "rg-bot-nprd-001"
  container_app_environment_id = var.container_app_environment_id
  revision_mode                = "Single"

  registry {
    server   = azurerm_container_registry.main.login_server
    identity = azurerm_user_assigned_identity.containerregistry.id
  }

  identity {
    type = "UserAssigned"
    identity_ids = [
      azurerm_user_assigned_identity.containerregistry.id,
      azurerm_user_assigned_identity.identity.id
    ]
  }

  ....

  template {
    container {
      env {
        name  = "Connections__BotServiceConnection__Settings__ClientId"
        value = azurerm_user_assigned_identity.identity.client_id
      }

      env {
        name  = "Connections__BotServiceConnection__Settings__TenantId"
        value = var.tenant_id
      }

      env {
        name  = "TokenValidation__Audiences__0"
        value = azurerm_user_assigned_identity.identity.client_id
      }

      env {
        name  = "TokenValidation__TenantId"
        value = var.tenant_id
      }
    }
  }
}

Application Gateway as the only ingress

Application Gateway is Azure’s Layer-7 (HTTP/HTTPS) reverse proxy and load balancer. It sits in front of your workload, terminates TLS, applies routing rules (host/path based), and then forwards the request to a backend target. By putting Application Gateway in front of the bot, you ensure that all inbound traffic goes through a single, controlled entry point. This allows you to enforce TLS, apply WAF rules, and have a clear audit trail.

resource "azurerm_public_ip" "appgw" {
  name                = "pip-appgw-bot-${var.environment}-001"
  location            = var.location
  resource_group_name = var.resource_group_name

  allocation_method = "Static"
  sku               = "Standard"
}

resource "azurerm_application_gateway" "main" {
  name                = "agw-bot-${var.environment}-001"
  location            = var.location
  resource_group_name = var.resource_group_name

  sku {
    name     = "WAF_v2"
    tier     = "WAF_v2"
    capacity = 2
  }

  # Must be a dedicated subnet (commonly named "AppGatewaySubnet")
  gateway_ip_configuration {
    name      = "appgw-ipcfg"
    subnet_id = "example-subnet-id"
  }

  frontend_port {
    name = "https-443"
    port = 443
  }

  frontend_ip_configuration {
    name                 = "public-frontend"
    public_ip_address_id = azurerm_public_ip.appgw.id
  }

  ssl_certificate {
    name     = "listener-cert"
    data     = var.ssl_cert_pfx_base64
    password = var.ssl_cert_password
  }

  http_listener {
    name                           = "https-listener"
    frontend_ip_configuration_name = "public-frontend"
    frontend_port_name             = "https-443"
    protocol                       = "Https"
    ssl_certificate_name           = "listener-cert"

    # This should match the public hostname you publish for the bot endpoint
    # (the same host used in the Azure Bot Service endpoint URL)
    host_name = var.bot_public_hostname
  }

  backend_address_pool {
    name  = "bot-backend-pool"
    fqdns = ["containerapps.commandbot.azurecontainerapps.io"]
  }

  backend_http_settings {
    name                                = "https-backend-settings"
    protocol                            = "Https"
    port                                = 443
    request_timeout                     = 30
    pick_host_name_from_backend_address = true
  }
}

The outcome is straightforward: even though the overall system supports inbound calls from Teams, the bot runtime itself is not directly exposed.

Azure Firewall for outbound control

Outbound restrictions are where enterprise networking gets real. The bot needs to reach a small set of endpoints for Bot Framework flows to work, and it may need to talk to other Azure services. By forcing outbound traffic through Azure Firewall, you can create explicit rules and verify what is and is not reachable.

For Bot Framework traffic you typically end up allowing endpoints such as login.botframework.comtoken.botframework.com, and smba.trafficmanager.net, and you may need to allow the Container Apps domain (for example *.azurecontainerapps.io) depending on your exact routing and control-plane needs.

Azure OpenAI with public network access disabled

If your bot uses Azure OpenAI, private access is often the “hard requirement” that drives the overall architecture. In Terraform you can deploy the account with public network access disabled and authenticate using managed identity. Besides the open ai resource we also need a deployment for the specific model we want to use, in this case gpt-4o-mini. With this setup, all traffic between the bot and Azure OpenAI stays on Microsoft’s backbone and never traverses the public internet.


resource "azurerm_cognitive_account" "openai" { name = "openai-ccoe-bot-${var.environment}-swe-001" kind = "OpenAI" sku_name = "S0" public_network_access_enabled = false custom_subdomain_name = "openai-ccoe-bot-${var.environment}-001" identity { type = "UserAssigned" identity_ids = [azurerm_user_assigned_identity.this.id] } } resource "azurerm_cognitive_deployment" "gpt_4o_mini" { name = "gpt-4o-mini" model { format = "OpenAI" name = "gpt-4o-mini" version = "2024-07-18" } sku { name = "GlobalStandard" capacity = 12 } }

One bot, many commands with Semantic Kernel

Semantic Kernel provides an orchestration layer that makes it easier to grow the bot over time. Instead of deploying a new bot for each capability, you can keep a single bot and add commands behind a consistent security model. In a real environment, you typically start with something like help and a small operational command, such as Update Members, and expand from there as the bot takes on more responsibilities.

The bot runtime in C#

On the application side, the bot is a regular ASP.NET app that exposes the Bot Framework endpoint and integrates Semantic Kernel. Keeping the application “platform-friendly” usually comes down to boring but important details: clean dependency injection, health checks, and clear boundaries between orchestration and integrations.

builder.Services.AddKernel();

builder.Services.AddAzureOpenAIChatCompletion(
   deploymentName: config.Azure.OpenAIDeploymentName,
   endpoint: config.Azure.OpenAIEndpoint,
   apiKey: config.Azure.OpenAIApiKey
);

app.MapPost("/api/messages", async (
    HttpRequest request,
    HttpResponse response,
    IAgentHttpAdapter adapter,
    IAgent agent,
    CancellationToken cancellationToken) =>
{
    await adapter.ProcessAsync(request, response, agent, cancellationToken);
});

builder.Services.AddHealthChecks();
app.MapHealthChecks("/health");

Summary

This architecture is more complex than a public endpoint and a secret, but you do it to reduce risk and make the platform auditable and controllable. Application Gateway gives you a single, inspectable ingress point (TLS termination, routing, WAF) instead of exposing the runtime directly. Azure Firewall makes outbound traffic explicit, so the bot can reach only the endpoints it truly needs. Managed identities and Key Vault remove secret sprawl and let you control access with RBAC. Private Azure OpenAI access keeps AI traffic off the public internet and supports stricter compliance requirements. Terraform ties it all together so the end state is reproducible, reviewable, and consistent across environments.

Github Repository

Written by

Victor de Baare

Victor de Baare is a developer with a deep passion for building systems that make work more efficient and straightforward. He thrives under pressure, persistently working towards his goals regardless of the challenge. He finds joy in whisky, food, gaming, and board games. He continues to inspire and innovate, making significant contributions to technology and efficiency.

Contact

Let’s discuss how we can support your journey.