Connects a Windows or Linux Web App β and optionally its deployment slots β to a source repository for deployment, for
hashicorp/azurerm ~> 4.0, with continuous integration as the default path.
- Creates an
azurerm_app_service_source_control(the keystonethis) β the deployment source binding that tells a Web App where its code comes from. - Supports external repositories with continuous integration (webhooks), manual integration, local Git, and Mercurial repositories.
- Optionally configures a GitHub Actions build via
code_configuration(a runtime stack) orcontainer_configuration(a container image and registry). - Binds source control to deployment slots through
azurerm_app_service_source_control_slot(for_eachover a keyed map), so staging and production slots can each point at their own branch. - Emits the binding
id, the resolvedscm_type, whether GitHub Actions is in use, and the map of slot binding IDs.
π‘ Why it matters: the source-control binding is what actually links a running Web App to a repo. This module keeps continuous integration the default (
use_manual_integration = false) and marks any registry passwordsensitive. Be precise about what that buys:sensitive = trueredacts plan output and does not encrypt state β the value is in the state file in plaintext, so the control that matters is an encrypted, access-controlled backend, never a local file in a repo.
If this module saves you time:
- β Star the repository
- πΌ Connect on LinkedIn
- β Buy me a coffee
flowchart LR
web["terraform-azurerm-linux-web-app / windows-web-app"]
repo["source repo (GitHub / local Git / Mercurial)"]
this["terraform-azurerm-app-service-source-control"]
sc["azurerm_app_service_source_control"]
slot["azurerm_app_service_source_control_slot (for_each)"]
web -->|"app_id"| this
repo -->|"repo_url + branch"| this
this -->|"keystone"| sc
this -->|"per slot"| slot
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
class this me;
class sc target;
class web,repo,slot ext;
flowchart TB
in["app_id + repo_url/branch + github_action_configuration"]
this["azurerm_app_service_source_control.this"]
slots["azurerm_app_service_source_control_slot (for_each)"]
out["Outputs: id, scm_type, source_control_slot_ids"]
in --> this
this -->|"per slot"| slots
this --> out
slots --> out
classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
class this target;
class slots me;
class in,out ext;
Resource inventory
| Resource | Role | Cardinality |
|---|---|---|
azurerm_app_service_source_control |
keystone this |
single |
azurerm_app_service_source_control_slot |
slot bindings | for_each (keyed map) |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| azurerm provider | ~> 4.0 |
| Provider block | None β the caller configures provider "azurerm" { features {} }, auth, and subscription |
Schema notes that bite (verified against the live schema):
- Everything is force-new. Every argument on both resources is immutable β
app_id/slot_id,repo_url,branch,use_manual_integration,use_local_git,use_mercurial,rollback_enabled, and the entiregithub_action_configuration. The resources are create/delete only; there is no in-place update and therefore noupdatetimeout. - Function apps are not supported.
app_idmust be a Windows or Linux Web App; a Function App is rejected. use_manual_integrationdefaults tofalseβ that is continuous integration (webhooks into the repo). Set it totruefor manual deploys.code_configuration.runtime_stackis a closed set:dotnetcore,spring,tomcat,node,python.registry_passwordis secret-bearing β wrappedsensitive()at render; provision it out of band.- No tags. The resource type does not support
tags; the universal tail here istimeouts(create / read / delete) only.
- Website Contributor (or equivalent) on the target Web App.
- An existing Windows or Linux Web App (not a Function App), and any existing deployment slots you intend to bind.
- Access to the source repository; for private repos, credentials or tokens provisioned out of band.
- For a container-based GitHub Actions build, a reachable container registry and (if private) its credentials.
- The caller configures the
provider "azurerm" { features {} }block, auth, and subscription β this module declares none of them.
terraform-azurerm-app-service-source-control/
βββ providers.tf # required_version + azurerm ~> 4.0 pin; no provider block
βββ variables.tf # keystone inputs, github_action_configuration, source_control_slots map, timeouts (no tags)
βββ main.tf # azurerm_app_service_source_control.this + for_each slot bindings
βββ outputs.tf # id first, then scm_type, uses_github_action, source_control_slot_ids
βββ README.md # this document
βββ SCOPE.md # cross-module contract
βββ LICENSE # MIT
βββ .gitignore
The smallest real call links a Web App to an external Git repo with continuous integration:
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/api"
branch = "main"
}βΉοΈ The caller configures
provider "azurerm" { features {} }, authentication, and the subscription β this module declares none of them.use_manual_integrationdefaults tofalse, so this call sets up continuous integration.
Consumes
| Input | Type | Source module |
|---|---|---|
app_id |
string |
terraform-azurerm-linux-web-app / windows-web-app (id) |
source_control_slots[*].slot_id |
string |
a Web App slot resource (id) |
github_action_configuration.container_configuration.registry_url |
string |
terraform-azurerm-container-registry (login_server) |
Emits
| Output | Description |
|---|---|
id |
Source control binding Resource ID (first) |
scm_type |
The resolved SCM type in use |
uses_github_action |
Whether GitHub Actions is used |
source_control_slot_ids |
Map: slot key β binding Resource ID |
1 Β· External Git repo with continuous integration (recommended)
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/api"
branch = "main"
}π‘
use_manual_integrationdefaults tofalseβ the provider wires webhooks so pushes tomaindeploy automatically.
2 Β· Manual integration (no webhooks)
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/api"
branch = "release"
use_manual_integration = true # deploy on demand instead of on push
}
β οΈ With manual integration the app is not re-deployed automatically; you trigger sync yourself.
3 Β· Local Git deployment
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
use_local_git = true # push to the App's built-in Git remote
}βΉοΈ Local Git uses the App Service's own remote β
repo_urlandbranchare not set.
4 Β· Mercurial repository
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://hg.example.com/contoso/api"
branch = "default"
use_mercurial = true # the repository is Mercurial, not Git
}5 Β· GitHub Actions build β Node.js code configuration
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/node-api"
branch = "main"
github_action_configuration = {
generate_workflow_file = true
code_configuration = {
runtime_stack = "node"
runtime_version = "20-lts"
}
}
}π‘
generate_workflow_file = truewrites the workflow YAML into the repo for you.
6 Β· GitHub Actions build β .NET Core code configuration
github_action_configuration = {
generate_workflow_file = true
code_configuration = {
runtime_stack = "dotnetcore"
runtime_version = "8.0"
}
}βΉοΈ
runtime_stackmust be one ofdotnetcore,spring,tomcat,node,python.
7 Β· GitHub Actions build β Python code configuration
github_action_configuration = {
code_configuration = {
runtime_stack = "python"
runtime_version = "3.12"
}
}8 Β· GitHub Actions build β container configuration (public image)
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/container-api"
branch = "main"
github_action_configuration = {
generate_workflow_file = true
container_configuration = {
image_name = "contoso/api:latest"
registry_url = "https://ghcr.io"
}
}
}9 Β· GitHub Actions build β private registry with credentials
github_action_configuration = {
container_configuration = {
image_name = "contoso/api:1.4.0"
registry_url = "https://contoso.azurecr.io"
registry_username = var.acr_username
registry_password = var.acr_password # sensitive β provision out of band
}
}π
registry_passwordis wrappedsensitive()at render and never emitted; pass a reference, do not commit it.
10 Β· Rollback enabled
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/api"
branch = "main"
rollback_enabled = true # allow deployment rollback
}
β οΈ rollback_enabledis force-new β toggling it replaces the binding.
11 Β· A single slot binding (staging)
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/api"
branch = "main"
source_control_slots = {
staging = {
slot_id = var.staging_slot_id
repo_url = "https://github.com/contoso/api"
branch = "develop" # staging tracks the develop branch
}
}
}π‘ Each slot binding mirrors the keystone fields but targets a slot via
slot_id.
12 Β· Multiple slots, each on its own branch
source_control_slots = {
staging = {
slot_id = var.staging_slot_id
repo_url = "https://github.com/contoso/api"
branch = "develop"
}
qa = {
slot_id = var.qa_slot_id
repo_url = "https://github.com/contoso/api"
branch = "qa"
use_manual_integration = true # QA deploys on demand
}
}βΉοΈ Keys (
staging,qa) are stable map keys β adding or removing one slot never re-indexes the others.
13 Β· Slot with a GitHub Actions container build
source_control_slots = {
staging = {
slot_id = var.staging_slot_id
repo_url = "https://github.com/contoso/container-api"
branch = "develop"
github_action_configuration = {
generate_workflow_file = true
container_configuration = {
image_name = "contoso/api:staging"
registry_url = "https://contoso.azurecr.io"
registry_username = var.acr_username
registry_password = var.acr_password # sensitive
}
}
}
}14 Β· Custom timeouts (create / delete only)
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = var.web_app_id
repo_url = "https://github.com/contoso/api"
branch = "main"
timeouts = { create = "30m", delete = "30m" } # no update β every field is force-new
}15 Β· ποΈ End-to-end composition
Stand up a resource group, plan, Linux web app with a staging slot, then bind both production and the slot to a repository with a Node.js GitHub Actions build:
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-web-eastus"
location = "eastus"
}
module "plan" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-service-plan.git?ref=v1.0.0"
name = "asp-contoso"
resource_group_name = module.rg.name
location = module.rg.location
os_type = "Linux"
sku_name = "P1v3"
}
module "web" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-linux-web-app.git?ref=v1.0.0"
name = "contoso-api"
resource_group_name = module.rg.name
location = module.rg.location
service_plan_id = module.plan.id
slots = { staging = {} } # a staging deployment slot
}
module "source_control" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-source-control.git?ref=v1.0.0"
app_id = module.web.id
repo_url = "https://github.com/contoso/api"
branch = "main"
github_action_configuration = {
generate_workflow_file = true
code_configuration = {
runtime_stack = "node"
runtime_version = "20-lts"
}
}
source_control_slots = {
staging = {
slot_id = module.web.slot_ids["staging"]
repo_url = "https://github.com/contoso/api"
branch = "develop"
}
}
}π‘ Production tracks
main; the staging slot tracksdevelop. The Web App must exist before its source-control binding β theapp_id/slot_idreferences express that ordering implicitly.
Required: app_id.
Repository selection: repo_url, branch, use_manual_integration, use_local_git, use_mercurial, rollback_enabled.
Build: github_action_configuration (code_configuration or container_configuration).
Slots: source_control_slots (keyed map, each with a slot_id).
Universal tail: timeouts (create / read / delete only β no tags, no update).
Full schemas
app_id = string # required, force-new; Windows/Linux Web App (not a Function App)
repo_url = optional(string) # force-new
branch = optional(string) # force-new
use_manual_integration = optional(bool) # null, NOT false: see below. Provider default is false;
# false = continuous integration; force-new
use_local_git = optional(bool) # use the App's local Git remote; conflicts with all of
# the others; force-new
use_mercurial = optional(bool) # null, not false. Repository is Mercurial; force-new
rollback_enabled = optional(bool) # null, not false; force-new
# The three defaults above are null rather than false ON PURPOSE. The provider declares
# use_local_git as ConflictsWith each of them, and the SDK tests whether an argument is
# PRESENT, not what it is set to. A false default renders the argument unconditionally and
# makes use_local_git unusable. Azure applies false server-side, so nothing changes but the
# conflict.
github_action_configuration = optional(object({
generate_workflow_file = optional(bool)
code_configuration = optional(object({
runtime_stack = string # dotnetcore | spring | tomcat | node | python
runtime_version = string
}))
container_configuration = optional(object({
image_name = string
registry_url = string
registry_username = optional(string)
registry_password = optional(string) # sensitive
}))
}))
source_control_slots = optional(map(object({
slot_id = string # the deployment slot's resource ID
repo_url = optional(string)
branch = optional(string)
use_manual_integration = optional(bool) # null, not false -- see the note above
use_local_git = optional(bool)
use_mercurial = optional(bool) # null, not false
rollback_enabled = optional(bool) # null, not false
github_action_configuration = optional(object({
generate_workflow_file = optional(bool)
code_configuration = optional(object({ runtime_stack = string, runtime_version = string }))
container_configuration = optional(object({
image_name = string
registry_url = string
registry_username = optional(string)
registry_password = optional(string) # sensitive
}))
}))
})), {})
timeouts = optional(object({ create = optional(string), read = optional(string), delete = optional(string) }))| Output | Description | Kind |
|---|---|---|
id |
Resource ID of the App Service Source Control (the keystone; emitted first) | Passthrough |
app_id |
Resource ID of the Web App this source-control binding is attached to | Passthrough |
scm_type |
The SCM type in use for the App Service, as decoded by Azure from the repository information supplied | Passthrough |
uses_github_action |
Whether Azure resolved this binding to a GitHub Actions deployment | Passthrough |
branch |
The branch deployments are taken from | Passthrough |
repo_url |
Passthrough | |
deployment_mode |
Passthrough | |
continuous_integration_enabled |
Whether pushes to the configured branch trigger a deployment | Derived |
github_action_configured |
Whether this call supplies a github_action_configuration block | Derived |
github_action_build_type |
Which build shape the GitHub Actions configuration describes: code, container, both, or none when no github_action_configuration is supplied | Passthrough |
generates_workflow_file_in_repository |
Derived | |
requires_repository_write_access |
Constant | |
github_credential_is_subscription_wide |
Always true | Constant |
container_configuration_is_flagged_unproven_upstream |
Always true | Constant |
has_registry_credentials |
Whether a container registry username or password was supplied for the GitHub Actions build | Derived |
github_action_describes_no_build |
True when a github_action_configuration is supplied that carries neither code_configuration nor container_configuration | Derived |
source_control_slot_ids |
Map of slot key to App Service Source Control Slot resource ID | Derived |
source_control_slot_count |
Number of deployment-slot source-control bindings this module manages | Passthrough |
source_control_slot_scm_types |
Map of slot key to the SCM type Azure decoded for that slot's repository | Derived |
source_control_slot_uses_github_action |
Map of slot key to whether Azure resolved that slot's binding to a GitHub Actions deployment | Derived |
source_control_slot_branches |
Map of slot key to the branch that slot deploys from | Derived |
slots_generating_workflow_files |
The keys of every slot whose configuration will cause Azure to commit a workflow file into the named repository | Passthrough |
slots_with_github_action_describing_no_build |
The keys of every slot supplying a github_action_configuration that carries neither code_configuration nor container_configuration | Passthrough |
is_create_and_delete_only |
Constant | |
destroy_resets_the_app_scm_type |
Constant | |
deploys_no_code_itself |
Constant | |
creation_fails_if_source_control_already_configured |
Always true | Constant |
- Every field is force-new. This resource has no update path β
app_id,repo_url,branch, the integration booleans,rollback_enabled, and the wholegithub_action_configurationall replace the binding when changed. That is why the module exposes onlycreate,read, anddeletetimeouts; there is deliberately noupdate. - CI is the default.
use_manual_integration = falsemeans the provider configures webhooks so repository pushes deploy the app. A caller must typetrueto opt into manual deploys. - Repository selection is a CONFLICT SET, not a menu. The provider declares
use_local_gitas conflicting withrepo_url,branch,use_manual_integration,uses_github_action,github_action_configuration,use_mercurialandrollback_enabledβ all seven β whilerepo_urlandbranchare mutually required. So an external repo setsrepo_url+branch; the App's built-in remote setsuse_local_gitand nothing else;use_mercurialmarks an external repo as Mercurial and cannot be combined with local Git. - The conflict check tests PRESENCE, not value β which is why three defaults are
nullhere. The SDK rejectsuse_local_gitalongside any conflicting argument that appears in the configuration at all, even set tofalse. Because a moduleoptional(bool, false)renders the argument unconditionally,use_manual_integration,use_mercurialandrollback_enableddefault tonullrather thanfalse: the provider appliesfalseserver-side, so behaviour is identical, but the pairing stays usable. A caller who typesfalseexplicitly on one of them still trips the conflict, and the provider's error names an argument they did not think they had set. - Function apps are out of scope. The resource type binds Web Apps only; passing a Function App
idis rejected by the platform. - Slot keys are stable.
source_control_slotsis a keyed map iterated withfor_each, so adding or removing one slot binding never re-indexes the rest. - Secrets are marked, not protected.
github_action_configuration.container_configuration.registry_passwordis wrappedsensitive()at render and never emitted. That redacts plan output; it does not encrypt state. Provision it out of band, pass a reference, and keep the state backend encrypted and access-controlled. - The bigger exposure is
repo_url, which the provider does not mark sensitive at all. It isOptional + Computed, so Azure's value is read back into state on every refresh. A URL carrying inline credentials βhttps://user:token@host/org/repo.gitβ therefore lands in state in plaintext and in unredacted plan output, wheresensitivewould at least have hidden it. The module rejects that shape at plan time; if such a URL has already been applied, treat the credential as compromised and reissue it. - The token is not on this resource, and Azure RBAC does not bound it. Authentication comes from the separate
azurerm_source_control_token, which is subscription-wide rather than per-app. Microsoft documents the token as needing write access to the repository, because Azure commits a workflow file into.github/workflows/on your behalf βgenerate_workflow_filedefaults totrue. Terraform neither manages that file, shows it in a plan, nor removes it on destroy. The blast radius is whatever that repository token can do on GitHub. - Destroying this binding edits the parent Web App. The provider's delete PATCHes the app's site config to
ScmType: Nonebefore removing the source control, so aterraform destroyhere mutates a resource a sibling module owns. Create is the mirror image: it refuses with a "requires import" error if the app'sScmTypeis not alreadyNone. features {}dependence. The provider will not initialize without a caller-sidefeatures {}block β expected, and owned by the root module.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Integration mode | use_manual_integration = false (continuous integration) |
set to true |
| Registry password | wrapped sensitive(); never emitted |
supply it (still sensitive) |
| Rollback | rollback_enabled = false |
set to true |
| Runtime stack | validated closed set (dotnetcore/spring/tomcat/node/python) |
β |
| Secret handling | no plaintext secret accepted or emitted beyond the sensitive registry password | β |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module by immutable tag (?ref=v1.0.0), never a branch. This is plan-only; a human applies from CI.
terraform validate+fmt -checkprove the type contract and theruntime_stackenum validation (keystone and every slot) offline, before any Azure call.- Only
terraform planagainst a subscription exercises the real Web App / slot existence, the repository's reachability, the GitHub Actions workflow generation, and registry credential validity.
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
id = "/subscriptions/.../resourceGroups/rg-web-eastus/providers/Microsoft.Web/sites/contoso-api/sourcecontrols/web"
scm_type = "GitHubAction"
uses_github_action = true
source_control_slot_ids = {
"staging" = "/subscriptions/.../providers/Microsoft.Web/sites/contoso-api/slots/staging/sourcecontrols/web"
}
| Symptom | Cause | Fix |
|---|---|---|
runtime_stack must be one of ... |
Unsupported code_configuration.runtime_stack |
Use dotnetcore, spring, tomcat, node, or python |
| Binding replaced on every change | Changed a force-new field (any field is force-new) | Expected β the resource is create/delete only |
no update timeout / plan wants replace on a small edit |
The resource has no in-place update | Accept the replacement, or avoid changing the binding |
| Function App binding fails | This resource does not support Function Apps | Bind a Windows/Linux Web App; use the Function App's own deployment settings |
| No automatic deploys after push | use_manual_integration = true |
Set it to false for continuous integration |
| Container pull fails at deploy | Missing/invalid registry credentials | Provide registry_username + registry_password for a private registry |
azurerm_app_service_source_controlazurerm_app_service_source_control_slot- Sibling modules:
terraform-azurerm-linux-web-app,terraform-azurerm-windows-web-app,terraform-azurerm-service-plan,terraform-azurerm-container-registry,terraform-azurerm-app-service-connection - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."