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 stages — provision, 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/createregisters the Entra ID (AAD) application and, optionally, mints a client secret. This is your app’s identity for SSO and Graph calls.arm/deployis the bridge to infrastructure-as-code. It invokes the Bicep CLI against yourinfra/*.biceptemplates withazure.parameters.json, then writes the deployment’s ARM outputs back into your env file.botFramework/createregisters the bot resource and binds themsteamschannel and messaging endpoint.teamsApp/zipAppPackagerenders yourmanifest.json(substituting env values), bundles the icons, and produces theappPackage.*.zip.teamsApp/update(oftenteamsApp/createon 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.devand 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:
- An Azure service principal for ARM/Bicep — exposed as
AZURE_SERVICE_PRINCIPAL_CLIENT_ID,AZURE_SERVICE_PRINCIPAL_CLIENT_SECRET,AZURE_TENANT_ID, andAZURE_SUBSCRIPTION_ID. Teams Toolkit detects these and authenticates non-interactively. - M365 credentials —
M365_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.
Let AI draft the Bicep, but never the consent
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
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.