Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure App Service Source Control Terraform Module

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.

Terraform azurerm Module Type Resources

🧩 Overview

  • Creates an azurerm_app_service_source_control (the keystone this) β€” 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) or container_configuration (a container image and registry).
  • Binds source control to deployment slots through azurerm_app_service_source_control_slot (for_each over a keyed map), so staging and production slots can each point at their own branch.
  • Emits the binding id, the resolved scm_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 password sensitive. Be precise about what that buys: sensitive = true redacts 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.

❀️ Support this project

If this module saves you time:

πŸ—ΊοΈ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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)

βœ… Provider / Versions

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 entire github_action_configuration. The resources are create/delete only; there is no in-place update and therefore no update timeout.
  • Function apps are not supported. app_id must be a Windows or Linux Web App; a Function App is rejected.
  • use_manual_integration defaults to false β€” that is continuous integration (webhooks into the repo). Set it to true for manual deploys.
  • code_configuration.runtime_stack is a closed set: dotnetcore, spring, tomcat, node, python.
  • registry_password is secret-bearing β€” wrapped sensitive() at render; provision it out of band.
  • No tags. The resource type does not support tags; the universal tail here is timeouts (create / read / delete) only.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Website Contributor (or equivalent) on the target Web App.

Azure Prerequisites

  • 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.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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_integration defaults to false, so this call sets up continuous integration.

πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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_integration defaults to false β€” the provider wires webhooks so pushes to main deploy 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_url and branch are 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 = true writes 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_stack must be one of dotnetcore, 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_password is wrapped sensitive() 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_enabled is 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 tracks develop. The Web App must exist before its source-control binding β€” the app_id / slot_id references express that ordering implicitly.

πŸ“₯ Inputs

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) }))

🧾 Outputs

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

🧠 Architecture Notes

  • Every field is force-new. This resource has no update path β€” app_id, repo_url, branch, the integration booleans, rollback_enabled, and the whole github_action_configuration all replace the binding when changed. That is why the module exposes only create, read, and delete timeouts; there is deliberately no update.
  • CI is the default. use_manual_integration = false means the provider configures webhooks so repository pushes deploy the app. A caller must type true to opt into manual deploys.
  • Repository selection is a CONFLICT SET, not a menu. The provider declares use_local_git as conflicting with repo_url, branch, use_manual_integration, uses_github_action, github_action_configuration, use_mercurial and rollback_enabled β€” all seven β€” while repo_url and branch are mutually required. So an external repo sets repo_url + branch; the App's built-in remote sets use_local_git and nothing else; use_mercurial marks 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 null here. The SDK rejects use_local_git alongside any conflicting argument that appears in the configuration at all, even set to false. Because a module optional(bool, false) renders the argument unconditionally, use_manual_integration, use_mercurial and rollback_enabled default to null rather than false: the provider applies false server-side, so behaviour is identical, but the pairing stays usable. A caller who types false explicitly 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 id is rejected by the platform.
  • Slot keys are stable. source_control_slots is a keyed map iterated with for_each, so adding or removing one slot binding never re-indexes the rest.
  • Secrets are marked, not protected. github_action_configuration.container_configuration.registry_password is wrapped sensitive() 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 is Optional + 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, where sensitive would 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_file defaults to true. 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: None before removing the source control, so a terraform destroy here mutates a resource a sibling module owns. Create is the mirror image: it refuses with a "requires import" error if the app's ScmType is not already None.
  • features {} dependence. The provider will not initialize without a caller-side features {} block β€” expected, and owned by the root module.

🧱 Design Principles

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 β€”

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module by immutable tag (?ref=v1.0.0), never a branch. This is plan-only; a human applies from CI.

πŸ§ͺ Testing

  • terraform validate + fmt -check prove the type contract and the runtime_stack enum validation (keystone and every slot) offline, before any Azure call.
  • Only terraform plan against a subscription exercises the real Web App / slot existence, the repository's reachability, the GitHub Actions workflow generation, and registry credential validity.

πŸ’¬ Example Output

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"
}

πŸ” Troubleshooting

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

πŸ”— Related Docs

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."