Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Function App Active Slot Terraform Module

Promotes a function app deployment slot into Production (azurerm_function_app_active_slot). An apply is a release, not convergent state. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🔁 Promotes an existing deployment slot into Production as one keystone resource named this.
  • ⚠️ An apply is a production swap. Changing slot_id performs another one; destroying the resource does not swap back.
  • 🧊 Documents the function-app-specific consequence: in-flight executions on the outgoing instance are not drained.
  • 🌐 Surfaces overwrite_network_config — the setting that quietly decides whether a release also promotes staging's network posture.
  • 🕓 Emits last_successful_swap as a release marker for downstream automation.
  • 🪞 Behaves identically to terraform-azurerm-web-app-active-slot, including its two documented deviations.

💡 Why it matters: most modules in this library describe a desired state. This one performs an operation. The plan shows a small diff and the apply changes what is running in production — so the value here is in the documentation making that unmistakable, and in overwrite_network_config being visible rather than buried.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

flowchart LR
  app["terraform-azurerm-function-app-linux or -windows"]
  slot["terraform-azurerm-function-app-linux-slot or -windows-slot"]
  cicd["a deployment pipeline puts code in the slot"]
  this["terraform-azurerm-function-app-active-slot"]
  sw["azurerm_function_app_active_slot"]
  prod["the app Production slot goes live"]
  peer["terraform-azurerm-web-app-active-slot"]

  app -->|"hosts the slot"| slot
  cicd -->|"deploys to"| slot
  slot -->|"slot_id"| this
  this -->|"creates"| sw
  sw -->|"promotes the slot into"| prod
  peer -->|"same design decisions, web app equivalent"| this

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class sw keystone;
  class app,slot,cicd,prod,peer sib;
Loading

🧬 What this module builds

flowchart TB
  slotid["slot_id: changing it performs another swap"]
  onc["overwrite_network_config: force-new, defaults true"]
  warn["true promotes the slot network posture into Production"]
  this["terraform-azurerm-function-app-active-slot"]
  sw["azurerm_function_app_active_slot.this"]
  drain["in-flight function executions are NOT drained"]
  nodestroy["destroy removes the record, it does not swap back"]
  out["outputs: id is the parent app id, slot_id, last_successful_swap"]

  slotid -->|"which slot goes live"| this
  onc -->|"release-time posture choice"| this
  onc -->|"left true by design"| warn
  this -->|"creates"| sw
  sw -->|"during the swap"| drain
  sw -->|"on terraform destroy"| nodestroy
  sw -->|"emits"| out

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class sw keystone;
  class slotid,onc,warn,drain,nodestroy,out sib;
Loading

Resource inventory

Resource Count Role
azurerm_function_app_active_slot.this 1 The keystone swap record, with its timeouts block.

ℹ️ Shape note. This resource is deliberately its own module rather than a feature of the slot modules. Creating a slot and choosing which slot is live are different decisions made at different times — a slot exists for the life of the app, while an active-slot record changes at every release.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite (verified against the live provider schema):

  • An apply is a production swap, not convergent state. Changing slot_id performs another swap. Destroying the resource removes Terraform's record and does not swap back — rollback is another swap, expressed as another apply.
  • The id is the parent function app's ID, not a distinct resource ID, because the record tracks which slot is live for that app. One active-slot resource per function app; a second one for the same app fights the first.
  • overwrite_network_config is force-new and defaults to true, matching the service. Left true, a swap promotes the staging slot's network configuration into Production — so a staging slot with no VNet integration, or a laxer IP-restriction set, carries that laxness into production along with the code.
  • In-flight function executions on the outgoing instance are not drained. This is the difference from a web app swap: a long-running queue trigger or a Durable Functions orchestration mid-execution can be interrupted.
  • Slot count depends on the hosting plan: Consumption gives 2 slots (production plus one), Premium 3, Dedicated 1-20 — and Flex Consumption does not support slots at all.
  • A swap on a cold app takes noticeably longer than on a warm one, which is why create and update deserve headroom.
  • This resource type has no tags surface.

🔑 Required Azure RBAC Roles / Permissions

  • Website Contributor or Contributor on the function app, or a custom role covering Microsoft.Web/sites/slots/slotsswap/action.
  • Note that this is effectively release authority: whoever holds it can change what runs in production without touching any code repository or pipeline. Scope it to the app, not the subscription.

Azure Prerequisites

  • An existing function app with at least one deployment slot, on a plan that supports slots. Consumption does (2 including production); Flex Consumption does not.
  • Code already deployed to the slot being promoted; a swap of an empty slot promotes an empty app.
  • A release window chosen deliberately — see the in-flight execution note above.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

📁 Module Structure

terraform-azurerm-function-app-active-slot/
├── providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf   # slot_id, overwrite_network_config, timeouts tail
├── main.tf        # keystone azurerm_function_app_active_slot.this; dynamic timeouts
├── outputs.tf     # id (parent app), slot_id, last_successful_swap, overwrite_network_config
├── README.md      # this document
├── SCOPE.md       # cross-module contract
├── LICENSE        # MIT
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "orders_release" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-active-slot.git?ref=v1.0.0"

  slot_id = module.orders_staging_slot.id

  # Production carries VNet integration that staging does not reproduce.
  overwrite_network_config = false

  timeouts = { create = "45m", update = "45m" }
}

ℹ️ The caller owns the provider, its authentication, and the mandatory features {} block. This module never declares them.


🔌 Cross-Module Contract

Consumes

Input Type Source module
slot_id string terraform-azurerm-function-app-linux-slot (id) or terraform-azurerm-function-app-windows-slot (id)

Emits

Output Description Consumed by
id The parent function app's Resource ID (first) audit inventories
slot_id The slot currently swapped into Production release review
last_successful_swap Timestamp of the last successful swap release markers, downstream automation
overwrite_network_config Whether the swap promotes the slot's network configuration governance review

📚 Example Library

The examples take deployment-slot IDs as inputs. slot_id is this module's only required argument, so the slot is always something that already exists when the swap is configured.

variable "slot_ids" {
  description = "Map of a stable key to the Resource ID of an existing Function App deployment slot."
  type        = map(string)
}
1 · A minimal swap
module "release" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-active-slot.git?ref=v1.0.0"

  slot_id = var.slot_ids["staging"]
}

⚠️ This applies the service default overwrite_network_config = true, which promotes staging's network configuration into Production. Read example 3 before shipping it.

2 · No `function_app_id` input
# The app is inferred from the slot — there is deliberately no function_app_id.
slot_id = var.slot_ids["staging"]

ℹ️ Adding a redundant app input would invite a mismatch the provider would then reject. Note the consequence for outputs: id is the parent app's ID, since the record tracks which slot is live for that app.

3 · Preserving Production's network configuration
overwrite_network_config = false

🔒 Set this to false whenever the Production slot carries network settings the staging slot does not reproduce — VNet integration, private-endpoint wiring, or an IP-restriction set. Leaving it true with a laxer staging configuration promotes that laxness into production along with the code, and nothing in the plan flags it.

4 · Why the default is not inverted
# The module leaves this at the service default of true, deliberately.
# overwrite_network_config = true

ℹ️ Inverting swap semantics would make Terraform behave differently from the portal and the Azure CLI — a difference you would discover during an incident, at the worst possible moment. Instead the field is documented in five places, emitted as an output, and set to false in every example where posture differs. This mirrors the identical decision in terraform-azurerm-web-app-active-slot, so the two behave the same way.

5 · In-flight executions are not drained
# A queue-triggered or Durable Functions app mid-orchestration can be interrupted
# by the swap. Schedule the release around the workload.
module "release" {
  source  = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-active-slot.git?ref=v1.0.0"
  slot_id = var.slot_ids["staging"]
}

⚠️ This is the real difference from a web app swap. A warm swap is not transparent for a function app: the outgoing instance is not given time to finish what it started. For a Durable Functions app, prefer a quiet window or a drain-and-pause step before the release.

6 · Rolling back
# Rollback is another swap, not a destroy.
slot_id = var.slot_ids["previous"]

⚠️ terraform destroy does not swap back. It removes Terraform's record of which slot is live and leaves production exactly as it is. To revert, point slot_id at the slot now holding the previous build and apply again.

7 · Using `last_successful_swap` as a release marker
output "orders_last_release" {
  description = "Timestamp of the last successful production swap for the orders function app."
  value       = module.orders_release.last_successful_swap
}

💡 This is genuinely useful in a pipeline: it is the service's own record of when production last changed, independent of your CI system's history.

8 · Timeout headroom for a cold app
timeouts = {
  create = "45m"
  read   = "5m"
  update = "45m"
  delete = "30m"
}

ℹ️ A swap on a warm, heavily loaded app takes several minutes; on a cold one it takes longer. The defaults are often enough and occasionally are not — give create and update room rather than discovering the limit mid-release.

9 · One active-slot resource per app
# ❌ Two active-slot resources against the same function app.
module "release_a" { slot_id = var.slot_ids["blue"] }
module "release_b" { slot_id = var.slot_ids["green"] }

⚠️ Both would resolve to the same id — the parent app's — so they fight, and each apply would swap away the other's work. Model a blue/green release as one resource whose slot_id changes.

10 · Blue/green with a variable
variable "live_slot" {
  description = "Which slot should be live: blue or green."
  type        = string
  default     = "blue"

  validation {
    condition     = contains(["blue", "green"], var.live_slot)
    error_message = "live_slot must be blue or green."
  }
}

module "release" {
  source  = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-active-slot.git?ref=v1.0.0"
  slot_id = var.live_slot == "blue" ? var.slot_ids["blue"] : var.slot_ids["green"]

  overwrite_network_config = false
}

💡 One resource, one variable, and the release becomes a reviewable one-line change. The plan will show slot_id changing — which is exactly the signal a release approver should be looking for.

11 · Gating the swap behind the deployment
module "release" {
  source  = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-active-slot.git?ref=v1.0.0"
  slot_id = var.slot_ids["staging"]

  # Ensure the slot exists and is configured before promoting it.
  depends_on = [module.staging_slot]
}

⚠️ depends_on orders the slot's creation, not its deployment. If code reaches the slot from a CI pipeline rather than from Terraform, the swap can still promote an empty or stale slot — sequence that in the pipeline, not here.

12 · Reviewing release posture across apps
output "release_review" {
  description = "Per-app release state. An overwrite_network_config of true means the last swap promoted staging's network posture."
  value = {
    for k, m in module.releases : k => {
      live_slot            = m.slot_id
      last_swap            = m.last_successful_swap
      overwrote_network    = m.overwrite_network_config
    }
  }
}

💡 overwrite_network_config is emitted precisely so this table can be built from state. A true on an app whose production slot has private networking is the row to question.

13 · Slots are unavailable on Flex Consumption
# ❌ A Flex Consumption function app has no deployment slots at all.
#    (The ordinary Consumption plan DOES have them -- 2, including production.)

⚠️ The failure arrives at apply, not at plan, because the plan tier is not an input to this module. Consumption gives 2 slots (production plus one), Premium 3, Dedicated 1-20 — and Flex Consumption does not support slots at all. Check the plan before wiring this module in.

14 · Importing an existing swap record
terraform import 'module.release.azurerm_function_app_active_slot.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-orders/providers/Microsoft.Web/sites/func-orders"

ℹ️ Note the ID is the function app's, not a slot's or a swap's — consistent with what the resource emits.

15 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

# 1 · The resource group.
module "orders_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-orders-eastus2"
  location = "eastus2"
}

# 2 · A plan that supports slots -- anything except Flex Consumption.
module "orders_plan" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-service-plan.git?ref=v1.0.0"

  name                = "asp-orders-eastus2"
  resource_group_name = module.orders_rg.name
  location            = module.orders_rg.location
  os_type             = "Linux"
  sku_name            = "P1v3"
}

# 3 · The function app. Its production posture includes VNet integration.
module "orders_storage" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
  name                = "storders0prod" # globally unique; choose your own
  resource_group_name = module.orders_rg.name
  location            = module.orders_rg.location
}

module "orders_vnet" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
  name                = "vnet-orders"
  resource_group_name = module.orders_rg.name
  location            = module.orders_rg.location
  address_space       = ["10.70.0.0/16"]

  subnets = {
    functions = {
      address_prefixes = ["10.70.1.0/24"]
      delegations = [{
        name               = "serverFarms"
        service_delegation = { name = "Microsoft.Web/serverFarms" }
      }]
    }
  }
}

module "orders_app" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-linux.git?ref=v1.0.0"

  name                = "func-orders"
  resource_group_name = module.orders_rg.name
  location            = module.orders_rg.location
  service_plan_id     = module.orders_plan.id
  storage_account_name = module.orders_storage.name

  virtual_network_subnet_id = module.orders_vnet.subnet_ids["functions"]
}

# 4 · The staging slot the pipeline deploys to.
module "orders_staging_slot" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-linux-slot.git?ref=v1.0.0"

  name                 = "staging"
  function_app_id      = module.orders_app.id
  storage_account_name = module.orders_storage.name
}

# 5 · The release — this module. One resource; the slot_id is the release lever.
module "orders_release" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-function-app-active-slot.git?ref=v1.0.0"

  slot_id = module.orders_staging_slot.id

  # Production has VNet integration that staging does not reproduce — do not
  # promote staging's network configuration along with the code.
  overwrite_network_config = false

  # A cold swap on a loaded app takes minutes.
  timeouts = { create = "45m", update = "45m" }

  depends_on = [module.orders_staging_slot]
}

# 6 · The release marker, for the pipeline to record.
output "orders_last_release" {
  description = "When production last changed, per the service's own record."
  value       = module.orders_release.last_successful_swap
}

💡 This wiring shows the whole release path: resource group → a slot-capable plan → the app with its production network posture → the staging slot a pipeline deploys into → the swap. Step 5 carries the decision that matters: overwrite_network_config = false, because step 3's VNet integration must survive the release. Note also that depends_on orders the slot's creation, not its deployment — getting code into the slot is the pipeline's job. Output names on sibling modules are illustrative; match them to the versions you pin.


📥 Inputs

Required: slot_id.

Release posture: overwrite_network_config (defaults to the service's true).

Universal tail: timeouts. This resource type does not support tags.

Full object() schemas
variable "slot_id" {
  type = string # the slot to promote. Changing it performs ANOTHER swap.
                # The parent app is inferred from this — there is no function_app_id input.
}

variable "overwrite_network_config" {
  type    = bool
  default = true # the SERVICE default, left uninverted deliberately — see Design Principles
                 # force-new
}

variable "timeouts" {
  type    = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })
  default = null
}

🧾 Outputs

Output Description Kind
id The Azure Resource ID of the Function App Active Slot Passthrough
function_app_id The parent Function App's Resource ID, reconstructed from slot_id by dropping the trailing "/slots/" Derived
slot_id The Resource ID of the deployment slot currently swapped into Production, as the provider read it back Passthrough
slot_name The name of the deployment slot being promoted, parsed from the last segment of the slot_id input Derived
function_app_name The name of the parent Function App, parsed from slot_id Derived
resource_group_name The resource group containing the Function App and its slots, parsed from slot_id Derived
subscription_id The subscription containing the Function App, parsed from slot_id Derived
last_successful_swap The timestamp of the last successful swap with Production, as reported by the app's swap status Passthrough
manages_no_azure_resource Always true Constant
destroy_performs_no_swap_back Constant
update_performs_another_swap Always true Constant
is_singleton_per_function_app Always true Constant
serializes_on_parent_app_lock Always true Constant
swap_poll_interval_seconds The interval, in seconds, at which the provider polls the app for swap completion during create and update Passthrough
swap_completion_is_detected_by_comparing_the_source_slot_name Constant
swap_may_terminate_executing_functions Always true, and it is the substantive difference from a web app swap Constant
swap_resets_function_keys_when_secret_storage_is_files Constant
slots_available_per_hosting_plan Derived
shares_its_implementation_with_web_app_active_slot Constant
slot_id_is_read_from_the_last_swap_source Constant
read_fails_when_app_has_no_swap_history Always true Constant
overwrite_network_config The value of overwrite_network_config in force for the next swap, as configured Passthrough
overwrite_network_config_read_back_from_azure Always false Constant
overwrite_network_config_defaults_to_true_on_import Always true Constant
overwrite_network_config_maps_to_preserve_vnet Constant
force_new_arguments The arguments that force a new record when changed Derived
slot_id_is_not_force_new Always true, and it is the surprising half of the lifecycle Constant
arm_api_version The ARM API version the provider uses for this resource: 2023-12-01 Derived
timeout_defaults The provider's built-in timeouts for this resource, which appear nowhere in the schema Derived
effective_timeouts The timeouts actually in force: the caller's values where supplied, the provider's defaults otherwise Derived

No secret is accepted or emitted. A swap record carries a slot reference and a flag.

🧠 Architecture Notes

  • This module performs an operation, not a state. An apply swaps production. Changing slot_id swaps again. A destroy removes Terraform's record and leaves production untouched — so destroy is not rollback; rollback is another swap. There is no configuration that makes a swap idempotent, so the honest treatment is to say so plainly rather than model it as convergent.
  • overwrite_network_config is left at the service default of true, against this suite's secure-by-default convention. Inverting swap semantics would make Terraform differ from the portal and CLI, and the moment you would discover that difference is during an incident. Instead the field is documented in the variable description, the schema notes, the design principles, and here; it is emitted as an output; and every example where production posture differs sets it to false. This is the identical decision taken for terraform-azurerm-web-app-active-slot, so the two modules behave the same way.
  • In-flight executions are not drained, and that is function-app-specific. A web app swap warms and shifts traffic; a function app's outgoing instance is not given time to finish a queue message or a Durable Functions orchestration. Releases should be scheduled around the workload rather than assumed transparent.
  • The id is the parent app's, which constrains the model. One active-slot resource per function app — a second resolves to the same ID and the two fight, each apply undoing the other. Blue/green is one resource with a changing slot_id, not two resources.
  • No function_app_id input. The app is inferred from the slot; a redundant input would only invite a mismatch the provider would reject.
  • depends_on orders creation, not deployment. If code arrives in the slot from a CI pipeline, Terraform cannot know whether the slot is ready — sequencing the deploy before the swap is the pipeline's job.
  • The plan tier is invisible here. Consumption gives 2 slots (production plus one), Premium 3, Dedicated 1-20 — and Flex Consumption does not support slots at all — but the plan is not an input, so a slot that does not exist is only discovered at apply.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Network posture on release not inverted — left at the service's true, and emitted as an output set false to preserve Production's configuration
Posture visibility overwrite_network_config and slot_id emitted — (no opt-out; that is the point)
Release semantics documented as non-convergent; destroy is not rollback — (no configuration changes this)
Redundant inputs no function_app_id — inferred from the slot — (structural)
Release authority documented as equivalent to production write access
Swap duration timeouts exposed with guidance to raise create / update accept the defaults

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 — never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.
  • ⚠️ For this module the human review is the release gate. Read the slot_id diff and the overwrite_network_config value before approving, and confirm the slot actually holds the intended build — Terraform cannot tell you that.

🧪 Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema. For this module that is a narrow claim — there are only two inputs and no enums to check.
  • terraform fmt -check enforces canonical formatting.
  • Neither command calls Azure. Only terraform plan (run by a human, from CI) exercises the ARM API — the module ships without any cloud apply. Whether the plan tier supports slots, whether the slot holds the intended build, and what a swap will do to in-flight executions are all apply-time or operational facts that no static check reaches.

💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id                       = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-orders-eastus2/providers/Microsoft.Web/sites/func-orders"
slot_id                  = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-orders-eastus2/providers/Microsoft.Web/sites/func-orders/slots/staging"
last_successful_swap     = "2026-07-28T14:22:11Z"
overwrite_network_config = false

🔍 Troubleshooting

Symptom Cause Fix
Provider configuration not present / features error No caller-side provider "azurerm" { features {} }. Add the provider block with features {} in the root module.
Apply fails: slots not supported The function app is on Flex Consumption, the one plan with no slot support. Move to Consumption, Premium or Dedicated, or use Flex Consumption's rolling site updates instead; the plan tier is not visible to this module.
Production lost its VNet integration after a release overwrite_network_config was left true and staging had no VNet integration. Set it to false; re-apply the app's network configuration.
terraform destroy did not roll back the release Destroy removes Terraform's record; it does not swap. Roll back by pointing slot_id at the previous slot and applying.
Queue messages or orchestrations were interrupted In-flight executions are not drained during a swap. Schedule releases around the workload; drain or pause before swapping.
Two active-slot resources keep undoing each other Both resolve to the same parent-app id. Use one resource whose slot_id changes.
The swap promoted an empty or stale app depends_on ordered the slot's creation, not its deployment. Sequence the deploy before the swap in the pipeline.
The swap timed out A cold or heavily loaded app takes longer than the defaults allow. Raise timeouts.create / timeouts.update.
Plan wants to replace the resource after changing the flag overwrite_network_config is force-new. Expected; confirm the replacement performs the swap you intend.
Unexpected production change with no code deploy Someone with slotsswap/action swapped outside the pipeline. Scope that permission to the app and treat it as release authority.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."