Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Mssql Managed Instance Terraform Module

A standalone, secure-by-default Terraform module for a single Azure SQL Managed Instance, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources


🧩 Overview

This module manages one Azure SQL Managed Instance and nothing else β€” it owns the keystone azurerm_mssql_managed_instance and its two inline blocks (identity and timeouts).

  • πŸ—„οΈ Provisions a SQL Managed Instance with an explicit SKU, vCore count, and storage size.
  • πŸ”’ Ships a private, TLS-1.2 posture: the public data endpoint is off and the transport floor is 1.2 on an empty call.
  • πŸ†” Attaches a system-assigned managed identity by default, so the instance has a Microsoft Entra identity without embedded credentials.
  • 🌐 Joins a caller-supplied subnet that must be delegated to Microsoft.Sql/managedInstances.
  • πŸ’Ύ Defaults backups to geo-redundant (GRS) storage for cross-region durability.
  • πŸ”‘ Accepts a SQL administrator password only as a sensitive input to be provisioned out of band, and documents Microsoft Entra-only authentication as the preferred path.

πŸ’‘ Why it matters: A SQL Managed Instance is a long-lived, network-anchored data platform whose defaults govern how exposed and how durable your database estate is. This module makes the hardened instance the default and forces every relaxation β€” public access, weaker TLS, no managed identity, local-only backups β€” to be typed out explicitly and reviewed.

❀️ Support this project

If this module saves you time, please consider supporting its ongoing maintenance:

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

flowchart LR
  rg["terraform-azurerm-resource-group"]
  vnet["terraform-azurerm-virtual-network (delegated subnet)"]
  nsg["terraform-azurerm-network-security-group"]
  rt["terraform-azurerm-route-table"]
  mi["terraform-azurerm-mssql-managed-instance"]
  aad["terraform-azurerm-mssql-managed-instance-active-directory-administrator"]
  tde["terraform-azurerm-mssql-managed-instance-transparent-data-encryption"]
  fog["terraform-azurerm-mssql-managed-instance-failover-group"]
  diag["terraform-azurerm-monitor-diagnostic-setting"]

  rg -->|"resource_group_name"| mi
  vnet -->|"subnet_id"| mi
  nsg -->|"secures subnet"| vnet
  rt -->|"routes subnet"| vnet
  mi -->|"id"| aad
  mi -->|"id"| tde
  mi -->|"dns_zone"| fog
  mi -->|"id"| diag

  classDef self fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#002240,color:#ffffff;
  classDef ext fill:#f1f3f5,stroke:#adb5bd,color:#212529;
  class mi self;
  class aad,tde,fog keystone;
  class rg,vnet,nsg,rt,diag ext;
Loading

🧬 What this module builds

flowchart TB
  subgraph inputs["Inputs"]
    rgn["resource_group_name"]
    loc["location"]
    sub["subnet_id (delegated)"]
    sku["sku_name / vcores / storage_size_in_gb"]
    idn["identity (SystemAssigned default)"]
  end

  mi["azurerm_mssql_managed_instance.this"]
  idblk["identity {} (inline)"]
  toblk["timeouts {} (inline)"]

  subgraph outputs["Outputs"]
    oid["id"]
    onm["name"]
    ofq["fqdn"]
    odns["dns_zone"]
    opid["identity_principal_id"]
  end

  rgn -->|"places"| mi
  loc -->|"places"| mi
  sub -->|"joins"| mi
  sku -->|"sizes"| mi
  idn -->|"renders"| idblk
  mi --- idblk
  mi --- toblk
  mi -->|"emits"| oid
  mi -->|"emits"| onm
  mi -->|"emits"| ofq
  mi -->|"emits"| odns
  idblk -->|"emits"| opid

  classDef self fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#002240,color:#ffffff;
  classDef ext fill:#f1f3f5,stroke:#adb5bd,color:#212529;
  class mi keystone;
  class idblk,toblk self;
  class rgn,loc,sub,sku,idn,oid,onm,ofq,odns,opid ext;
Loading

Resource inventory

Resource Count Role
azurerm_mssql_managed_instance.this 1 The keystone managed instance
identity {} (inline dynamic) 0–1 System- or user-assigned managed identity
timeouts {} (inline dynamic) 0–1 Optional operation timeouts

βœ… 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):

  • name, resource_group_name, location, collation, and timezone_id are immutable β€” changing any forces replacement. location and resource_group_name take their ForceNew from the provider's shared schema helpers, so they do not appear in a plain search of the resource's own source.
  • administrator_login is immutable too, and this is the costliest surprise here. Renaming the SQL administrator destroys the instance and builds a new one, with a 24-hour timeout allowed for each half. administrator_login_password is not immutable β€” rotating the password is an ordinary in-place update.
  • Three fields are force-new in one direction only, each by way of the provider's CustomizeDiff rather than a schema marker, so nothing but the plan warns:
    • dns_zone_partner_id β€” replaces only on the empty β†’ set transition. Changing it between two partner IDs, or clearing it, updates in place.
    • identity β€” replaces only on removal of the last identity. Adding one is in-place. The provider cites an open Azure REST API defect.
    • database_format β€” replaces only on the AlwaysUpToDate β†’ SQLServer2022 downgrade. The upgrade is in-place.
  • subnet_id is updatable in the current provider line, but the subnet must be delegated to Microsoft.Sql/managedInstances and dedicated to managed instances; a non-conforming subnet fails at apply, not at plan.
  • minimum_tls_version accepts 1.0, 1.1 and 1.2 on the pinned ~> 4.0 line, and proxy_override accepts Default, Proxy and Redirect. The registry documentation states narrower sets for both β€” it describes the forthcoming 5.0 provider, whose schema block 4.x replaces with a wider override. This module accepts the real 4.x sets, defaults to the locked-down value, and reports a weakened TLS floor through an output rather than refusing it.
  • proxy_override has no schema default on 4.x (it is Optional + Computed); the provider substitutes Default at create time when the argument is omitted. An empty call therefore yields Default, not Redirect.
  • storage_iops applies only when general_purpose_v2_enabled = true and is refused on any BC_ SKU; general_purpose_v2_enabled cannot be true on a BC_ SKU. All three rules live in CustomizeDiff and are mirrored by this module's validation {} blocks so they fail offline instead.
  • zone_redundant_enabled is available on both General Purpose and Business Critical β€” it is only the Next-gen General Purpose tier that excludes it. Microsoft additionally requires storage_account_type of ZRS or GZRS before zone redundancy can be enabled; the provider does not check that pairing, so this module does.
  • administrator_login_password and dns_zone_partner_id are never returned by the Azure API β€” the provider's read sets both back from prior state, so drift in either is permanently invisible to terraform plan.
  • Provisioning is slow: create, update, and delete each default to a 24-hour service timeout. Deleting the last instance in a subnet also tears down the virtual cluster, which Microsoft documents at around 90 minutes, against about a minute for any other instance.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • SQL Managed Instance Contributor on the target resource group (create/update/delete the instance), or a custom role granting Microsoft.Sql/managedInstances/* at that scope.
  • Network Contributor (or Microsoft.Network/virtualNetworks/subnets/join/action) on the delegated subnet so the instance can join it.
  • Managed Identity Operator on any user-assigned identity you attach.
  • Least-privilege: scope each role to the smallest resource group / subnet that works, not the subscription.

Azure Prerequisites

  • The Microsoft.Sql resource provider registered on the target subscription.
  • An existing resource group in a supported US Azure region.
  • A dedicated subnet delegated to Microsoft.Sql/managedInstances, with an associated network security group and route table (user-defined routes), not shared with other resources.
  • Sufficient regional vCore quota for the chosen SKU and vCore count.
  • For customer-managed transparent data encryption: an existing Key Vault and key, with the instance identity granted access (wired via the sibling modules).
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; this module declares none of these.

πŸ“ Module Structure

terraform-azurerm-mssql-managed-instance/
β”œβ”€β”€ providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf   # deeply-typed object() schemas; secure defaults; tags + timeouts tail
β”œβ”€β”€ main.tf        # keystone azurerm_mssql_managed_instance.this; dynamic identity + timeouts
β”œβ”€β”€ outputs.tf     # id first, then name, fqdn, dns_zone, identity principal/tenant IDs
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore     # canonical library ignore set

βš™οΈ Quick Start

The smallest real call. The caller configures the provider, auth, and the mandatory features {} block.

provider "azurerm" {
  features {}
}

module "sql_managed_instance" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-platform-eastus2"
  resource_group_name = "rg-data-platform"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 8
  storage_size_in_gb = 256
  subnet_id          = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network/providers/Microsoft.Network/virtualNetworks/vnet-core/subnets/snet-sqlmi"
  license_type       = "LicenseIncluded"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password # provisioned out of band; never committed

  tags = {
    environment = "production"
    workload    = "data-platform"
  }
}

πŸ”’ On an empty call the instance is private (public_data_endpoint_enabled = false), enforces minimum_tls_version = "1.2", carries a system-assigned identity, and uses geo-redundant backups.

πŸ”Œ Cross-Module Contract

Consumes

Input Type From
resource_group_name string resource-group module output name
location string caller / resource group location
subnet_id string virtual-network module subnet_ids (a subnet delegated to Microsoft.Sql/managedInstances)
identity.identity_ids set(string) user-assigned-identity module id (user-assigned identity only)
dns_zone_partner_id string a partner mssql-managed-instance module id (failover-group topologies)

Emits

Output Description Consumed by
id SQL Managed Instance Resource ID Entra admin / TDE / failover group / diagnostics / role assignments
name Instance name diagnostics / tagging conventions
fqdn Fully qualified domain name application connection strings
dns_zone DNS zone the instance lives in a partner instance's dns_zone_partner_id
identity_principal_id Principal ID of the managed identity (or null) Key Vault / RBAC grants for customer-managed TDE
identity_tenant_id Tenant ID of the managed identity (or null) Entra-scoped grants

πŸ“š Example Library

1 Β· Minimal secure General Purpose instance

The empty-ish call: private, TLS 1.2, system-assigned identity, geo-redundant backups.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-app-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 4
  storage_size_in_gb = 128
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

πŸ’‘ Everything not set here inherits the module's secure defaults β€” you type only what the workload needs.

2 Β· Business Critical, zone-redundant

Business Critical adds local SSD, higher IOPS, and read replicas; zone redundancy spreads them across availability zones.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-core-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name               = "BC_Gen5"
  vcores                 = 16
  storage_size_in_gb     = 512
  subnet_id              = var.sqlmi_subnet_id
  license_type           = "LicenseIncluded"
  zone_redundant_enabled = true
  storage_account_type   = "GZRS" # required: zone redundancy needs zonal backup storage

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

⚠️ Zone redundancy requires storage_account_type of ZRS or GZRS. The module default is GRS, so leaving it at the default here would produce a configuration Azure refuses at apply β€” this module refuses it at validate instead. Zone redundancy is available on General Purpose as well as Business Critical; only the Next-gen General Purpose tier excludes it. It is also region-limited, costs more for both compute and backups, and Microsoft documents enabling it as taking around four hours.

3 Β· Bring-your-own-license (Azure Hybrid Benefit)

Use existing SQL Server licenses by setting the base-price license model.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-byol-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 8
  storage_size_in_gb = 256
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "BasePrice"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

ℹ️ license_type = "LicenseIncluded" (the other legal value) bills the SQL license with the instance.

4 Β· Explicit TLS 1.2 floor

The secure default made explicit for auditability.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-tls-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name            = "GP_Gen5"
  vcores              = 4
  storage_size_in_gb  = 128
  subnet_id           = var.sqlmi_subnet_id
  license_type        = "LicenseIncluded"
  minimum_tls_version = "1.2"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

πŸ”’ Legal values are 1.0, 1.1, 1.2. Lowering the floor weakens transport security and is generally rejected by the service.

5 Β· Connection policy β€” proxy override Redirect

Redirect gives lower latency after the handshake but requires additional ports.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-redirect-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 4
  storage_size_in_gb = 128
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"
  proxy_override     = "Redirect"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

⚠️ Redirect requires ports 1433 and 11000-11999 open in the subnet's network security group. Legal values are Default, Proxy, Redirect.

6 Β· Public data endpoint (explicit opt-in)

Exposing the public endpoint is a deliberate relaxation of the secure default.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-public-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name                     = "GP_Gen5"
  vcores                       = 4
  storage_size_in_gb           = 128
  subnet_id                    = var.sqlmi_subnet_id
  license_type                 = "LicenseIncluded"
  public_data_endpoint_enabled = true
  proxy_override               = "Proxy"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

⚠️ Enabling the public data endpoint accepts traffic from outside the VNet on port 3342. Pair it with tight network-security-group rules and keep it off unless a public consumer truly requires it.

7 Β· User-assigned managed identity

Attach one or more user-assigned identities instead of the default system-assigned one.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-uai-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 4
  storage_size_in_gb = 128
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"

  identity = {
    type         = "UserAssigned"
    identity_ids = [var.uai_id]
  }

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

ℹ️ identity_ids is required whenever type includes UserAssigned.

8 Β· Both system- and user-assigned identity

Combine the platform identity with one or more caller-owned identities.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-dualid-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 8
  storage_size_in_gb = 256
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"

  identity = {
    type         = "SystemAssigned, UserAssigned"
    identity_ids = [var.uai_id]
  }

  service_principal_type = "SystemAssigned"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

πŸ’‘ Set identity = null to opt out of a managed identity entirely β€” but prefer an identity over embedded credentials.

9 Β· Next-gen General Purpose with provisioned IOPS

The Next-gen General Purpose tier exposes provisioned storage IOPS.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-gpv2-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name                   = "GP_Gen5"
  vcores                     = 8
  storage_size_in_gb         = 256
  subnet_id                  = var.sqlmi_subnet_id
  license_type               = "LicenseIncluded"
  general_purpose_v2_enabled = true
  storage_iops               = 4000

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

⚠️ storage_iops (range 300–80000) applies only when general_purpose_v2_enabled = true, which is incompatible with zone_redundant_enabled.

10 Β· Custom collation and timezone (immutable)

Both fields are force-new; set them at creation and treat them as fixed.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-collation-centralus"
  resource_group_name = "rg-data"
  location            = "centralus"

  sku_name           = "GP_Gen5"
  vcores             = 4
  storage_size_in_gb = 128
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"

  collation   = "Latin1_General_100_CI_AS_SC"
  timezone_id = "UTC"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

⚠️ Changing collation, timezone_id, or dns_zone_partner_id after creation forces the instance to be replaced.

11 Β· Backup redundancy β€” zone-redundant backup storage

Tune the backup storage account type independently of the compute tier.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                 = "mi-gzrs-eastus2"
  resource_group_name  = "rg-data"
  location             = "eastus2"

  sku_name             = "GP_Gen5"
  vcores               = 8
  storage_size_in_gb   = 256
  subnet_id            = var.sqlmi_subnet_id
  license_type         = "LicenseIncluded"
  storage_account_type = "GZRS"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

ℹ️ Legal values are GRS (default), GZRS, LRS, ZRS. Prefer a geo- or zone-redundant option for regulated data.

12 Β· Custom operation timeouts

Managed instance operations are slow; the service defaults are 24 hours. Override deliberately.

module "mi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-timeouts-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "GP_Gen5"
  vcores             = 4
  storage_size_in_gb = 128
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"

  timeouts = {
    create = "24h"
    update = "24h"
    delete = "24h"
    read   = "5m"
  }

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

⚠️ Do not shorten the create timeout below what your region typically needs β€” a truncated timeout fails the apply mid-provision.

13 Β· Failover-group-ready pair (shared DNS zone)

Create a primary, then a secondary that shares the primary's DNS zone via dns_zone_partner_id.

module "mi_primary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-primary-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = "BC_Gen5"
  vcores             = 8
  storage_size_in_gb = 256
  subnet_id          = var.sqlmi_subnet_primary
  license_type       = "LicenseIncluded"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

module "mi_secondary" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-secondary-westus2"
  resource_group_name = "rg-data-dr"
  location            = "westus2"

  sku_name            = "BC_Gen5"
  vcores              = 8
  storage_size_in_gb  = 256
  subnet_id           = var.sqlmi_subnet_secondary
  license_type        = "LicenseIncluded"
  dns_zone_partner_id = module.mi_primary.id

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

# The failover group itself is a sibling module wired against both instance ids.

⚠️ dns_zone_partner_id is immutable β€” set it at creation of the secondary.

14 Β· for_each at scale β€” a map of instances

Instantiate many instances from a keyed map so adding or removing one never re-indexes the rest.

locals {
  instances = {
    app  = { sku_name = "GP_Gen5", vcores = 4, storage_size_in_gb = 128 }
    core = { sku_name = "BC_Gen5", vcores = 16, storage_size_in_gb = 512 }
  }
}

module "mi" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"
  for_each = local.instances

  name                = "mi-${each.key}-eastus2"
  resource_group_name = "rg-data"
  location            = "eastus2"

  sku_name           = each.value.sku_name
  vcores             = each.value.vcores
  storage_size_in_gb = each.value.storage_size_in_gb
  subnet_id          = var.sqlmi_subnet_id
  license_type       = "LicenseIncluded"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password
}

πŸ’‘ Keys (app, core) are the stable identity β€” renaming a key replaces that instance, so keep keys meaningful and fixed.

15 Β· πŸ—οΈ End-to-end composition

Wire a resource group, a virtual network with a delegated subnet, a network security group, a route table, and this managed instance into one topology. Sibling outputs feed this module's inputs.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-data-platform"
  location = "eastus2"
}

module "nsg" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-group.git?ref=v1.0.0"
  name                = "nsg-sqlmi"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "route_table" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
  name                = "rt-sqlmi"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

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

  subnets = {
    sqlmi = {
      name                      = "snet-sqlmi"
      address_prefixes          = ["10.20.1.0/27"]
      network_security_group_id = module.nsg.id
      route_table_id            = module.route_table.id
      delegations = {
        managedinstance = {
          service_delegation_name    = "Microsoft.Sql/managedInstances"
          service_delegation_actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
        }
      }
    }
  }
}

module "sql_managed_instance" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "mi-platform-eastus2"
  resource_group_name = module.rg.name
  location            = module.rg.location

  sku_name           = "GP_Gen5"
  vcores             = 8
  storage_size_in_gb = 256
  subnet_id          = module.vnet.subnet_ids["sqlmi"]
  license_type       = "LicenseIncluded"

  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password

  tags = {
    environment = "production"
    workload    = "data-platform"
  }
}

ℹ️ The Entra administrator, transparent data encryption, and failover group are separate sibling modules; wire them against module.sql_managed_instance.id (and .dns_zone for failover groups). Subnet argument names follow the virtual-network module's own schema.

πŸ“₯ Inputs

Required

Name Type Description
name string Globally unique instance name (immutable)
resource_group_name string Existing resource group (immutable)
location string Azure region (immutable)
sku_name string Service tier + hardware generation (validated enum)
vcores number vCore count for the SKU
storage_size_in_gb number Storage size in GB; 32–32768, capped at 16384 unless general_purpose_v2_enabled
subnet_id string Delegated subnet resource ID (anchored pattern)
license_type string LicenseIncluded or BasePrice β€” compared case-insensitively, as the provider does
administrator_login string SQL administrator login. Required through this module (see below), and immutable
administrator_login_password string SQL administrator password. Required through this module, sensitive, 8–128 characters

⚠️ The two administrator inputs are required, and that is a deliberate departure from this suite's usual preference for optional inputs. The provider marks both Optional, but pairs each β€” through AtLeastOneOf β€” with the inline azure_active_directory_administrator block. This module does not expose that block (the Entra administrator is a sibling module with its own lifecycle), and because AtLeastOneOf names arguments of the same resource, a separate Entra administrator resource cannot satisfy it. Omitting them fails at plan with a provider error, so the module declares them required and the failure lands at parse time instead. Provision the password out of band and pass a reference.

Common optional (secure defaults)

Name Type Default Description
minimum_tls_version string "1.2" Transport floor. 1.0 / 1.1 / 1.2 are all accepted on ~> 4.0; the module defaults to the floor and reports a weakened value rather than refusing it
public_data_endpoint_enabled bool false Public endpoint off by default (port 3342 when enabled)
storage_account_type string "GRS" Backup redundancy, not data storage. ZRS/GZRS required for zone redundancy
identity object { type = "SystemAssigned" } Managed identity; type closed set, identity_ids required when type includes UserAssigned and each entry pattern-checked
proxy_override string null Connection policy. Default / Proxy / Redirect on ~> 4.0; omitting it yields Default
zone_redundant_enabled bool false Requires ZRS/GZRS backups; excluded on Next-gen General Purpose
general_purpose_v2_enabled bool null Next-gen General Purpose. Refused on BC_ SKUs; the only tier that bills IOPS
storage_iops number null 300–80000; requires general_purpose_v2_enabled, refused on BC_ SKUs
tags map(string) {} Resource tags; at most 50, names ≀512 and values ≀256 characters
timeouts object null Operation timeouts; Go duration strings, provider defaults are 24h/5m/24h/24h
Full input schemas
variable "identity" {
  type = object({
    type         = string                        # SystemAssigned | UserAssigned | "SystemAssigned, UserAssigned"
    identity_ids = optional(set(string), [])     # required when type includes UserAssigned
  })
  default = { type = "SystemAssigned" }
}

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

Other optional inputs: collation (string, null, immutable), timezone_id (string, null, immutable), maintenance_configuration_name (string, null, validated SQL_Default or SQL_{Location}_MI_{Number}), hybrid_secondary_usage (string, null, validated Active/Passive), database_format (string, null, validated AlwaysUpToDate/SQLServer2022; the downgrade forces replacement), dns_zone_partner_id (string, null, anchored managed-instance ID pattern; force-new only on empty β†’ set), service_principal_type (string, null, validated SystemAssigned).

collation and timezone_id are checked only for non-emptiness, matching the provider β€” SQL Server ships thousands of collations and the Windows time zone set varies by build, so a list short enough to maintain would refuse legal values. A misspelling in either passes validate and plan and is refused by Azure at apply.

🧾 Outputs

Output Description Notes
id SQL Managed Instance Resource ID Emitted first; the reference every sibling module consumes
name Instance name
resource_group_name Resource group containing the instance
location Azure region, in the canonical form Azure uses Read from the resource, not var.location
fqdn Fully qualified domain name For connection strings; public only when the public endpoint is on, and then on port 3342
dns_zone DNS zone the instance lives in Confirms two instances share a zone. The partner argument takes an instance ID, not this string
subnet_id Delegated subnet hosting the instance
identity_principal_id Managed identity principal ID null when no identity is configured
identity_tenant_id Managed identity tenant ID null when no identity is configured
user_assigned_identity_ids User-assigned identities attached Empty set for a purely system-assigned identity
primary_user_assigned_identity_is_nondeterministic More than one user-assigned identity is attached The provider picks the primary from an unordered map; the choice is not stable across runs
sku_name SKU as Azure reports it Normalised by the provider β€” Azure returns BC_G8IH / MIBC64G8IH for BC_Gen8IH
vcores vCore count
storage_size_in_gb Allocated storage in GB
public_data_endpoint_enabled Public endpoint posture For a policy assertion without re-reading the input
backup_storage_is_geo_redundant Backups replicate cross-region (GRS/GZRS) False means a geo-restore has nothing to restore from
tls_floor_is_weakened minimum_tls_version is not 1.2 Reported, not refused β€” refusing would block destroy
proxy_override_effective Connection policy actually in force An omitted value yields Default on ~> 4.0, not Redirect
admin_credentials_are_mandatory_through_this_module Always true AtLeastOneOf cannot be satisfied by the sibling Entra module
changing_admin_login_replaces_this_instance Always true The login is force-new; the password is not
credential_and_partner_drift_is_invisible Always true The API returns neither the password nor the partner ID
database_format_downgrade_replaces_this_instance Always true The upgrade is in-place; only the downgrade rebuilds
removing_identity_replaces_this_instance Always true Adding an identity is in-place; removing the last one rebuilds
iops_is_separately_billed Next-gen General Purpose is selected The only tier that bills IOPS, above a free quota
storage_size_is_multiple_of_32 Size matches Microsoft's documented increment Reported, not enforced
deleting_the_last_instance_in_a_subnet_is_slow Always true ~90 minutes, against ~1 minute for any other instance

No output carries a secret. administrator_login_password is deliberately not re-emitted: it is already in this module's state, and emitting it would copy it into every consuming configuration's state as well, for no benefit.

πŸ”’ The administrator password is an input only and is never emitted as an output.

🧠 Architecture Notes

  • Immutability is broad. name, resource_group_name, location, collation, timezone_id, and dns_zone_partner_id are all force-new β€” treat them as fixed at creation. Plan a new instance rather than an in-place change when any of these must move.
  • The subnet is a hard contract. subnet_id must reference a subnet delegated to Microsoft.Sql/managedInstances, dedicated to managed instances, and paired with a network security group and a route table. This is not enforced at plan; a non-conforming subnet fails during the (long) apply.
  • Provisioning is measured in hours. The service defaults create/update/delete to 24 hours. CI pipelines and human runbooks must budget for this; the timeouts input exists to raise, not casually lower, these.
  • Identity by default. identity defaults to a system-assigned identity so identity_principal_id is available to grant Key Vault or RBAC access (for example for customer-managed TDE via the sibling module). Set identity = null only when you deliberately want no managed identity.
  • Authentication path. SQL authentication needs administrator_login + administrator_login_password at creation. The password is a sensitive input to be provisioned out of band. Microsoft Entra-only authentication β€” the preferred path β€” is configured via the sibling active-directory-administrator module and lets you omit the SQL password entirely.
  • features {} dependence. The provider will not initialize without a caller-side provider "azurerm" { features {} } block. That is expected; this module intentionally declares no provider block.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller types it)
Public data endpoint public_data_endpoint_enabled = false set to true
Transport security minimum_tls_version = "1.2" set 1.1 / 1.0
Managed identity identity = { type = "SystemAssigned" } set a different type or null
Backup redundancy storage_account_type = "GRS" (geo-redundant) set LRS / ZRS / GZRS
Secrets password is sensitive, provisioned out of band; prefer Entra-only auth pass a plaintext value (discouraged)

Rules of thumb: a boolean that gates exposure defaults to the closed value; an enum that includes a "public"/"allow-all" member defaults to the locked-down member and is enforced with a validation {} block; the module never emits a secret.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 β€” never track a branch.
  • This module is plan-only during authoring and review; a human runs terraform apply from CI against real credentials.
  • Because apply can run for hours, schedule managed-instance changes in a window that tolerates long operations.

πŸ§ͺ Testing

The offline proof gate covers what can be verified without touching Azure:

  • terraform init -backend=false resolves and installs the pinned azurerm ~> 4.0 provider.
  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema β€” every object() schema and validation {} block is exercised here.
  • terraform fmt -check enforces canonical formatting.

What only a human-run terraform plan/apply from CI can exercise: subnet delegation conformance, regional vCore quota, SKU/vCore/storage combinations, and the multi-hour provisioning itself. This library never runs plan or apply during authoring.

πŸ’¬ Example Output

Outputs:

id                    = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-platform/providers/Microsoft.Sql/managedInstances/mi-platform-eastus2"
name                  = "mi-platform-eastus2"
fqdn                  = "mi-platform-eastus2.a1b2c3d4e5f6.database.windows.net"
dns_zone              = "a1b2c3d4e5f6"
identity_principal_id = "11111111-2222-3333-4444-555555555555"
identity_tenant_id    = "99999999-8888-7777-6666-555555555555"

πŸ” Troubleshooting

Symptom Cause Fix
SubnetIsNotDelegatedToProvider at apply Subnet is not delegated to Microsoft.Sql/managedInstances Delegate the subnet (see the end-to-end composition) and re-apply
VnetSubnetConflictWithIntendedPolicy / subnet in use Subnet is shared with other resources Use a dedicated subnet for the managed instance
Apply times out during create Default provisioning can take hours Keep the 24-hour create timeout; do not shorten it
provider not initialized / features error Missing caller-side features {} block Add provider "azurerm" { features {} } in the root module
Plan shows full replacement on a small edit Changed an immutable field (name, location, collation, timezone_id, dns_zone_partner_id) Revert the field or accept replacement deliberately
identity_ids error on user-assigned identity type includes UserAssigned but identity_ids is empty Supply at least one user-assigned identity ID
storage_iops rejected Set without general_purpose_v2_enabled = true Enable Next-gen General Purpose or drop storage_iops
Cannot omit administrator password Instance created with SQL authentication Configure Microsoft Entra-only auth via the sibling administrator module

πŸ”— Related Docs

  • Provider resource: azurerm_mssql_managed_instance
  • Azure SQL Managed Instance concepts: Microsoft Learn β€” SQL Managed Instance
  • Subnet delegation and connectivity: Microsoft Learn β€” SQL Managed Instance VNet architecture
  • Sibling modules: terraform-azurerm-resource-group, terraform-azurerm-virtual-network, terraform-azurerm-network-security-group, terraform-azurerm-route-table, terraform-azurerm-mssql-managed-instance-active-directory-administrator, terraform-azurerm-mssql-managed-instance-transparent-data-encryption, terraform-azurerm-mssql-managed-instance-failover-group, terraform-azurerm-monitor-diagnostic-setting
  • This module's SCOPE.md for the full cross-module contract.

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