Skip to content
CloudOps
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 11 min read

Provision and Deploy Teams Apps With Teams Toolkit and Bicep

Scaffolding a Teams app is easy; getting its Azure infra reproducible is not. Here's the Teams Toolkit provision/deploy lifecycle backed by Bicep, in CI.

  • #microsoft-teams
  • #teams-toolkit
  • #bicep
  • #azure
  • #infrastructure-as-code

The first time I demoed a Teams app, the scaffolding took ninety seconds and everyone clapped. The first time I tried to ship that same app to a real tenant from CI — no F5, no local debug profile, no friendly login popup — I lost the better part of a Friday. The gap between “it runs on my laptop” and “it provisions reproducibly in three environments” is where Teams development actually lives, and almost nobody writes about it.

So this is the post I wish I’d had. Not how to scaffold a tab or a bot — the templates do that fine. This is about the provision/deploy lifecycle that Teams Toolkit drives, how it wires into Bicep for your Azure infrastructure, and how to run the whole thing headless in a pipeline with a service principal instead of a human clicking through consent screens.

The yml file is the whole game

Everything starts with the project file. Depending on your toolkit version it’s named teamsapp.yml (Teams Toolkit) or m365agents.yml (the M365 Agents Toolkit rebrand) — same engine underneath. This file is not config; it’s an executable pipeline. It defines three lifecycle stagesprovision, deploy, and publish — and each stage is an ordered list of actions.

When you run teamsapp provision, the CLI walks the provision: list top to bottom, executing each action and threading outputs into your environment file. deploy does the same for the deploy: list. That’s the entire mental model: stages are make-targets, actions are steps, and an .env file is the shared blackboard between them.

# teamsapp.yml (trimmed provision section)
version: 1.0.0

provision:
  - uses: aadApp/create
    with:
      name: my-teams-bot-aad-${{TEAMSFX_ENV}}
      generateClientSecret: true
      signInAudience: AzureADMyOrg
    writeToEnvironmentFile:
      clientId: AAD_APP_CLIENT_ID
      clientSecret: SECRET_AAD_APP_CLIENT_SECRET
      objectId: AAD_APP_OBJECT_ID
      tenantId: AAD_APP_TENANT_ID

  - uses: arm/deploy
    with:
      subscriptionId: ${{AZURE_SUBSCRIPTION_ID}}
      resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}}
      templates:
        - path: ./infra/azure.bicep
          parameters: ./infra/azure.parameters.json
          deploymentName: Create-resources-${{TEAMSFX_ENV}}
      bicepCliVersion: v0.9.1

  - uses: botFramework/create
    with:
      botId: ${{BOT_ID}}
      name: my-teams-bot
      messagingEndpoint: ${{BOT_DOMAIN}}/api/messages
      description: ""
      channels:
        - name: msteams

Read that as a recipe. Register an AAD app and capture its IDs. Run the Bicep to stand up Azure resources. Register the bot against the Bot Framework using values the previous steps produced. Each step’s writeToEnvironmentFile block is what makes the chain work.

Actions, briefly, and why each one matters

A handful of actions carry most Teams apps:

  • aadApp/create registers the Entra ID (AAD) application and, optionally, mints a client secret. This is your app’s identity for SSO and Graph calls.
  • arm/deploy is the bridge to infrastructure-as-code. It invokes the Bicep CLI against your infra/*.bicep templates with azure.parameters.json, then writes the deployment’s ARM outputs back into your env file.
  • botFramework/create registers the bot resource and binds the msteams channel and messaging endpoint.
  • teamsApp/zipAppPackage renders your manifest.json (substituting env values), bundles the icons, and produces the appPackage.*.zip.
  • teamsApp/update (often teamsApp/create on first run) pushes that package to the Teams Developer Portal so the app is installable.

The ordering is deliberate: identity before infra, infra before bot registration, manifest packaging last because it needs the IDs everything else generated.

Pro Tip: arm/deploy is the only action most teams should ever heavily customize. Resist the urge to bolt one-off az cli shell steps into the yml — push that work into Bicep so it stays declarative and idempotent. Every imperative shell step you add is a future state-drift bug.

Bicep is where the real infrastructure lives

The yml registers identities; Bicep builds the things that cost money. Your infra/ folder typically holds azure.bicep (the entrypoint), maybe a botRegistration/ module, and azure.parameters.json. Here’s a trimmed App Service plus plan — the host for a bot or message-extension backend:

@maxLength(20)
@minLength(4)
param resourceBaseName string
param location string = resourceGroup().location
param serverfarmsName string = resourceBaseName
param webAppSKU string = 'B1'

@secure()
param aadAppClientSecret string
param aadAppClientId string
param aadAppTenantId string

resource serverfarm 'Microsoft.Web/serverfarms@2021-02-01' = {
  name: serverfarmsName
  location: location
  sku: { name: webAppSKU }
  kind: 'app'
  properties: { reserved: false }
}

resource webApp 'Microsoft.Web/sites@2021-02-01' = {
  name: resourceBaseName
  location: location
  properties: {
    serverFarmId: serverfarm.id
    httpsOnly: true
    siteConfig: {
      appSettings: [
        { name: 'BOT_ID', value: aadAppClientId }
        { name: 'BOT_TENANT_ID', value: aadAppTenantId }
        { name: 'RUNNING_ON_AZURE', value: '1' }
      ]
    }
  }
}

// These outputs get written straight back into .env.dev
output BOT_AZURE_APP_SERVICE_RESOURCE_ID string = webApp.id
output BOT_DOMAIN string = webApp.properties.defaultHostName

Notice the output statements. After arm/deploy finishes, Teams Toolkit reads those outputs and stamps them into your env file — BOT_DOMAIN becomes available for the very next action (botFramework/create, which needs the messaging endpoint). The Bicep and the yml communicate through that env blackboard; neither hard-codes the other’s values.

Mark secrets @secure() so they never land in deployment history in plaintext. The aadAppClientSecret flows in from the SECRET_-prefixed env var that aadApp/create produced.

The env files are state, treat them like it

Teams Toolkit keeps one env file per environment: .env.local for F5 debugging, .env.dev for your dev tenant, and you add .env.staging / .env.prod as needed. Two halves matter:

  • Non-secret outputs — resource IDs, client IDs, domains — live in .env.dev and should be committed. They’re how a teammate (or CI) knows which resources already exist.
  • Secrets — anything SECRET_-prefixed — go into .env.dev.user, which is gitignored. Locally these get encrypted; in CI you inject them from your secret store.

This split is the source of idempotency. On the second provision, aadApp/create sees AAD_APP_CLIENT_ID already populated and skips re-creating the app. arm/deploy runs an ARM incremental deployment, which is a no-op if the desired state already matches. Re-running provision should be boring — that’s the whole point. If a re-run keeps creating duplicate resources, you almost certainly aren’t persisting the env outputs between runs.

Pro Tip: In CI, restore .env.{env} from your repo and write the matching .env.{env}.user from pipeline secrets at job start. Skip that and every pipeline run thinks it’s the first run, and you’ll wake up to a resource group full of orphaned AAD app registrations.

Running it headless in CI with a service principal

Local provisioning leans on an interactive m365 and az login. CI can’t click. The fix is two service identities:

  1. An Azure service principal for ARM/Bicep — exposed as AZURE_SERVICE_PRINCIPAL_CLIENT_ID, AZURE_SERVICE_PRINCIPAL_CLIENT_SECRET, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID. Teams Toolkit detects these and authenticates non-interactively.
  2. M365 credentialsM365_ACCOUNT_NAME / M365_ACCOUNT_PASSWORD, or a configured app for app-only flows — for the Teams Developer Portal and AAD actions.

A minimal pipeline job looks like this:

npm install -g @microsoft/teamsapp-cli

# secrets arrive as masked CI variables — never echoed, never logged
teamsapp provision --env dev --interactive false
teamsapp deploy   --env dev --interactive false

--interactive false is the load-bearing flag: it forbids the CLI from ever prompting, so a missing secret fails loudly instead of hanging a runner for an hour. provision stands up infra and registrations; deploy builds your app code and pushes it to the App Service the Bicep created. Keep them as separate steps — you’ll often provision once per environment and deploy on every merge.

This is exactly the kind of work where a model earns its keep. The Teams Toolkit yml schema and ARM/Bicep are verbose, repetitive, and well-documented — perfect for an LLM. I treat tools like Claude or GitHub Copilot as a fast junior engineer: “draft me an arm/deploy block plus a Bicep module for a Cosmos DB account with these outputs wired back to env vars.” It produces a credible first pass in seconds, complete with the @secure() annotations I’d otherwise forget.

But a junior engineer’s PR gets reviewed before it touches a tenant, and so does the model’s. I read every line, because the failure modes are quiet and expensive: an over-broad signInAudience that makes your AAD app multi-tenant by accident, a webAppSKU that silently provisions a pricey tier, an output that leaks a connection string into ARM deployment history. The model drafts; the human verifies security before anything runs against real Azure.

The hard rule: never hand the model real tenant credentials or a service principal secret. Not in a prompt, not in a pasted .env.dev.user, not “just to debug.” Those belong in your secret store and your CI variables, full stop. If you want a structured way to keep these review conversations honest, the prompt workspace and our prompt library have templates for exactly this kind of infra-review pass, and the prompt packs bundle the IaC-review ones together. For more Teams-specific walkthroughs, the Microsoft Teams category collects the rest.

Wrapping up

Scaffolding gets the applause; the lifecycle gets the uptime. Once you internalize that the teamsapp.yml is an ordered pipeline, that arm/deploy is your one true seam into Bicep, and that the env files are real state you must persist, headless CI provisioning stops being a Friday-killer and becomes a boring, repeatable green check. Let the AI draft the boilerplate fast — then review it like you’d review any junior’s first deploy to production. Because that’s exactly what it is.

Return slug: provision-and-deploy-teams-apps-with-teams-toolkit-and-bicep

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.