Chooses which key encrypts an Azure SQL logical server's data at rest β service-managed, Key Vault, or Managed HSM β and reports what that choice costs. Targets
hashicorp/azurerm ~> 4.0.
- π Manages
azurerm_mssql_server_transparent_data_encryptionβ the single encryption protector record on an Azure SQL logical server. - ποΈ Accepts a versioned Key Vault key or a versioned Managed HSM key. They are mutually exclusive.
- π‘οΈ Leaves the empty call already encrypted: no key means a Microsoft-managed key, and transparent data encryption stays on.
- π Passes
auto_rotation_enabledthrough, reports when it governs nothing, and reports the one combination where it guarantees a permanent diff β rotation on, against the pinned version this resource insists on. - π§Ύ Emits the parsed vault, key name and key version, plus the facts a plan cannot show you.
β οΈ Reports the overlap that matters most:azurerm_mssql_serverowns this same setting.
π‘ Why it matters: transparent data encryption is always on for an Azure SQL server. The only question is who holds the key β and taking it is a transfer of control, not an extra layer of protection. This module makes that transfer explicit, refuses the identifiers the provider will refuse anyway, and names the one overlap that produces a permanent diff instead of an error.
If this module saved you time:
- β Star the repository β it is the cheapest signal that this work is worth continuing.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
SRV["terraform-azurerm-mssql-server"]
KV["terraform-azurerm-key-vault"]
HSM["terraform-azurerm-key-vault-managed-hardware-security-module-key"]
TDE["terraform-azurerm-mssql-server-transparent-data-encryption"]
MITDE["terraform-azurerm-mssql-managed-instance-transparent-data-encryption"]
RA["terraform-azurerm-role-assignments"]
RG -->|"name to resource_group_name"| SRV
SRV -->|"id to server_id"| TDE
KV -->|"key_ids[k] to key_vault_key_id"| TDE
HSM -->|"versioned_id to managed_hsm_key_id"| TDE
SRV -->|"identity_principal_id"| RA
KV -->|"id to scope"| RA
SRV -.->|"OWNS THE SAME FIELD as transparent_data_encryption_key_vault_key_id -- pick one"| TDE
TDE -.->|"managed-instance twin: versionless key allowed"| MITDE
classDef self fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
class TDE self
class SRV keystone
class RG,KV,HSM,RA,MITDE ext
The server module feeds id in; a key module feeds a versioned identifier in. The dotted edge back from the server is not a data flow β it is the warning: azurerm_mssql_server carries its own transparent_data_encryption_key_vault_key_id argument that writes this very record. Pick one of the two. The dotted edge to the managed-instance twin marks the same idea on a different keystone, with a different rule about versions.
flowchart TB
V1["server_id"]
V2["key_vault_key_id (versioned)"]
V3["managed_hsm_key_id (versioned, deprecated)"]
V4["auto_rotation_enabled"]
V5["timeouts"]
THIS["azurerm_mssql_server_transparent_data_encryption.this"]
O1["id -- .../encryptionProtector/current"]
O2["key_kind -- service_managed / key_vault / managed_hsm"]
O3["vault_name, key_name, key_version"]
O4["rotation_setting_is_inert"]
O5["posture flags -- control transfer, revert on destroy"]
V1 --> THIS
V2 --> THIS
V3 --> THIS
V4 --> THIS
V5 --> THIS
THIS --> O1
THIS --> O2
THIS --> O3
THIS --> O4
THIS --> O5
classDef self fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
class THIS keystone
class O1 self
class V1,V2,V3,V4,V5,O2,O3,O4,O5 ext
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_mssql_server_transparent_data_encryption |
1 (this) |
One per server. Its Resource ID always ends /encryptionProtector/current β there is no name to choose. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module. The caller configures provider "azurerm" { features {} }, including authentication. |
Schema notes that bite β each verified against the live provider schema and the resource's own source:
- π΄
azurerm_mssql_serverowns this same setting. Its optionaltransparent_data_encryption_key_vault_key_idargument writes the same encryption protector. Use exactly one. Setting both does not fail cleanly: the server's read writes the live protector key into the server's own state unconditionally, and that argument is optional rather than computed β so a server whose configuration leaves it empty plans a change back to empty on every run once this module sets a key, and the server's update then hands that empty string to a key-identifier parser. - π΄ The key must ALWAYS be versioned here. Both key arguments are validated versioned-only, unconditionally.
https://kv.vault.azure.net/keys/tdeis refused;https://kv.vault.azure.net/keys/tde/abc123β¦is required. - π΄ The managed-instance twin is different. On
azurerm_mssql_managed_instance_transparent_data_encryption, a versionless key is legal whenever auto-rotation is on and the provider resolves the current version. Do not carry that assumption across. β οΈ auto_rotation_enabledtherefore decides nothing about legality on this resource. It is passed through; the key must be versioned either way.- π΄ But it is far from inert β
auto_rotation_enabled = truehere buys a PERMANENT DIFF. Azure's automated rotation is off by default; switched on, the server watches the vault and moves the protector to the newest key version within 24 hours of one appearing. This resource requires a pinned version and reads the live version straight back, so every plan after that first rotation proposes putting the older version back β and applying it does exactly that. The twin escapes this by accepting a versionless value and suppressing the diff; there is no equivalent here. Reported asazure_rotates_past_the_pinned_version, not refused. - βΉοΈ Versioned-only is the PROVIDER's rule, not the service's. Azure SQL Database itself supports a versionless TDE protector and resolves the latest enabled version; the restriction belongs to this provider release. Worth knowing before treating the constraint as immovable.
- π΄ Making a key the protector creates a SECOND ARM record that Terraform never tracks. The key is registered on the server as
Microsoft.Sql/servers/<server>/keys/<vault>_<key>_<version>before the protector is repointed. Nothing removes it β not a key change, not a destroy, not the revert to a service-managed key β so one accumulates per key version ever used. Emitted asserver_key_idbecause no other state refers to it. β οΈ managed_hsm_key_idis deprecated here and current on the twin, in the same provider release. Supplying a value printsWarning: Argument is deprecatedatterraform validate; leaving it null is silent, because the warning is raised on the value.β οΈ The provider populates BOTH key fields for an HSM key, because an HSM key identifier is also a valid key identifier. State showing both is normal.- π΄ The exclusion between the two key arguments is decided by an argument's PRESENCE, not by its value β and the two disagree about what empty means. An empty string in the unused slot is refused exactly as a second real key would be:
key_vault_key_id = ""beside a Managed HSM key failsterraform validatewith"key_vault_key_id": conflicts with managed_hsm_key_id.managed_hsm_key_idis worse, because the provider's create tests it for an explicit null rather than for emptiness β an empty string there reaches a key-identifier parser and the apply fails, with nothing visible offline. That is not a theoretical value: an empty string is exactly what this resource's read returns for whichever key argument is unused, so a composition wiring one instance's key outputs into another's inputs supplies one. This module renders both arguments through a normalizing local so a blank arrives as an absent argument, and its own mutual-exclusion check counts a blank as unset for the same reason. Reported asan_empty_string_is_not_the_same_as_an_unset_key. β οΈ Destroy reverts, it does not delete. Removing this resource returns the server to a service-managed key. Encryption is never switched off.β οΈ timeouts.deleteis accepted and IGNORED β the delete reads the create/update deadline. An undeclaredtimeoutskey is discarded silently, with no error.- βΉοΈ Only
server_idis force-new. The keys and the rotation flag update in place. - βΉοΈ The resource declares a state upgrader (schema version 1 from 0), so older state is migrated on first use.
- βΉοΈ No
tagsand nolocation. Tag the SQL server instead.
Least privilege, at the smallest scope that works.
| Scope | Permission | Why |
|---|---|---|
| The SQL logical server | Microsoft.Sql/servers/encryptionProtector/read |
Refresh and plan. |
| The SQL logical server | Microsoft.Sql/servers/encryptionProtector/write |
Create, update, and the revert-on-destroy. |
| The SQL logical server | Microsoft.Sql/servers/keys/write |
Registering the key on the server precedes making it the protector. |
| The SQL logical server | Built-in fit: SQL Server Contributor | Or a custom role with exactly the actions above. Contributor at the resource group works and grants far more. |
π The key permissions belong to the SERVER, not to your Terraform principal. The server's managed identity needs
get,wrapKeyandunwrapKeyon the vault β a data-plane grant made with the Key Vault Crypto Service Encryption User role (or an access policy on a vault still using them). Nothing here can see whether that grant exists; the failure arrives at apply as a service error, not at plan.
βΉοΈ Plan access is not key access here. This module references a key identifier and never reads key material, so no secret is accepted or emitted.
- The
Microsoft.Sqlresource provider registered in the subscription. - An existing Azure SQL logical server. A managed instance is refused by this module's anchored ID check β it has its own encryption-protector resource.
- For a customer-managed key only:
- A Key Vault or Managed HSM with soft delete and purge protection enabled. Azure SQL will not accept a key from a vault without purge protection.
- A versioned key identifier.
- The SQL server configured with a managed identity, and that identity granted
get,wrapKeyandunwrapKey. - Network reachability from the SQL service to the vault, if the vault restricts network access.
- Nothing at all for the empty call. With no key, the server keeps service-managed encryption.
terraform-azurerm-mssql-server-transparent-data-encryption/
βββ providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
βββ variables.tf # 5 inputs, 6 validations, deeply-typed with the schema in the descriptions
βββ main.tf # one keystone `this`; dynamic timeouts; try() on every optional field
βββ outputs.tf # 46 outputs: id first, then identity, then the posture facts
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
The smallest real call keeps the Microsoft-managed key and simply brings the protector under management:
provider "azurerm" {
features {}
}
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
}βΉοΈ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
Consumes
| Input | Type | Source module |
|---|---|---|
server_id |
string |
terraform-azurerm-mssql-server β id |
key_vault_key_id |
string (optional) |
terraform-azurerm-key-vault β key_ids["<key>"] |
managed_hsm_key_id |
string (optional) |
terraform-azurerm-key-vault-managed-hardware-security-module-key β versioned_id |
auto_rotation_enabled |
bool (optional, false) |
caller |
timeouts |
object (optional) |
caller |
Emits
| Output | Consumed by |
|---|---|
id |
audit tooling, policy exemptions |
server_id, server_name, resource_group_name, subscription_id |
compositions and reporting |
key_kind, this_server_uses_a_customer_managed_key |
posture reporting |
vault_name, key_name, key_version |
key-rotation tooling |
server_key_name, server_key_id |
audit and cleanup of the untracked server-key records |
rotation_setting_is_inert, azure_rotates_past_the_pinned_version, rotating_the_key_means_changing_this_configuration |
drift review |
| the posture constants | human readers |
1 Β· The empty call β service-managed encryption, brought under management
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
}π This is already encrypted. Transparent data encryption is on with a Microsoft-managed key, and there is no configuration that turns it off.
2 Β· A customer-managed key from Key Vault
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_ids["sql-tde"]
}
β οΈ The identifier must carry a version.module.key_vault.key_ids[...]is the key resource'sid, which is versioned β that is why it works here.
3 Β· The versionless key that is refused β and why
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_versionless_ids["sql-tde"] # refused
}π΄ Refused offline, before any Azure call, by this module's own version check β which names the versionless case specifically rather than reporting a generic shape error. On the managed-instance twin the same value would be legal whenever auto-rotation is on; the rule does not transfer. βΉοΈ Worth knowing that this is the provider's restriction, not the platform's: Azure SQL Database accepts a versionless TDE protector and resolves the latest enabled version itself. That is also why
auto_rotation_enabled = truebehaves awkwardly here β see example 6.
4 Β· A Managed HSM key
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "sql-tde"
managed_hsm_id = var.managed_hsm_id
key_type = "RSA-HSM"
key_size = 2048 # required for RSA-HSM by the sibling module
key_opts = ["wrapKey", "unwrapKey"]
}
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
managed_hsm_key_id = module.hsm_key.versioned_id
}
β οΈ Supplying this argument printsWarning: Argument is deprecatedat plan β it is deprecated in favour ofkey_vault_key_idand removed in the provider's 5.0 line. See example 5 for the migration.
5 Β· The same HSM key, without the deprecation warning
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
# `key_vault_key_id` already accepts a Managed HSM identifier -- which is
# exactly why the provider's read populates both fields with the same value.
key_vault_key_id = module.hsm_key.versioned_id
}π‘ This is the migration, and it is available today. It removes the warning and pre-empts the 5.0 removal.
6 Β· Auto-rotation β what it does not change, and the diff it buys
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_ids["sql-tde"] # still versioned
auto_rotation_enabled = true
}
output "still_needs_a_version" {
value = module.sql_tde.the_key_must_always_be_versioned_here
}
output "and_will_therefore_drift" {
value = module.sql_tde.azure_rotates_past_the_pinned_version # true
}
β οΈ On this resource the flag does not make a versionless identifier legal. The key must be versioned either way, and nothing in Terraform's validation depends on this flag. π΄ It does, however, guarantee drift in this exact combination. Azure will move the protector to a newer key version within 24 hours of one appearing, the provider reads that newer version back, and every later plan proposes returning to the version pinned above β which an apply will actually do. Either pin deliberately with rotation off, or keep this configuration away from any pipeline that fails on a non-empty plan.
7 Β· The flag set with no key at all β reported, not refused
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
auto_rotation_enabled = true
}
output "rotation_governs_nothing" {
value = module.sql_tde.rotation_setting_is_inert # true
}βΉοΈ A service-managed key is rotated by Microsoft on its own schedule. The value is accepted and simply does not apply β so the module reports it rather than rejecting a value the provider allows.
8 Β· Both keys at once β refused, with the reason
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_ids["sql-tde"]
managed_hsm_key_id = module.hsm_key.versioned_id # rejected at plan
}π΄ The provider declares these mutually exclusive. This module refuses the pairing offline with a message that says which one to keep.
9 Β· Reading the key back apart β vault, name and version
output "tde_key" {
value = {
kind = module.sql_tde.key_kind # "key_vault"
vault = module.sql_tde.vault_name # "kv-data-platform"
key = module.sql_tde.key_name # "sql-tde"
version = module.sql_tde.key_version # never null under a customer key
}
}π‘
key_versionis never null when a customer key is in effect, precisely because this resource demands the version. On the managed-instance twin it can be.
10 Β· Granting the server what it actually needs
module "sql_server" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server.git?ref=v1.0.0"
name = "sql-platform-eastus"
resource_group_name = module.rg.name
location = module.rg.location
azuread_administrator = {
login_username = "sql-admins"
object_id = var.sql_admin_group_object_id
}
identity = { type = "SystemAssigned" }
}
module "tde_key_access" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.key_vault.id
role_assignments = {
sql_wrap_unwrap = {
principal_id = module.sql_server.identity_principal_id
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_type = "ServicePrincipal"
description = "Lets the SQL server wrap and unwrap its TDE protector key."
}
}
}π This grant is the server's, not yours. Without it the key is set and the apply fails at the service β nothing in the plan shows the gap.
11 Β· The overlap that produces a permanent diff
# DO NOT DO THIS -- two resources claiming one setting.
module "sql_server" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server.git?ref=v1.0.0"
name = "sql-platform-eastus"
resource_group_name = module.rg.name
location = module.rg.location
azuread_administrator = {
login_username = "sql-admins"
object_id = var.sql_admin_group_object_id
}
transparent_data_encryption_key_vault_key_id = module.key_vault.key_ids["sql-tde"] # <-- and
}
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_ids["sql-tde"] # <-- the same setting again
}π΄ Choose one. Use the server argument when this configuration owns the server and a versioned Key Vault key is all you need. Use this module when you need a Managed HSM key or
auto_rotation_enabled, or when the server is not yours to manage β then leave the server's argument unset, because a server whose configuration leaves it empty plans a change back to empty on every run once the protector is set elsewhere.
12 Β· Reporting the posture across a fleet
locals {
servers = {
platform = module.sql_tde_platform
reporting = module.sql_tde_reporting
archive = module.sql_tde_archive
}
}
output "tde_posture" {
value = {
for name, m in local.servers : name => {
key_kind = m.key_kind
customer_managed = m.this_server_uses_a_customer_managed_key
rotation_is_inert = m.rotation_setting_is_inert
key_version = m.key_version
}
}
}π‘ A fleet report that names which servers took the key and which left it with Microsoft β the question an audit actually asks.
13 Β· Timeouts, including the one that is ignored
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_ids["sql-tde"]
timeouts = {
create = "45m"
read = "10m"
update = "45m"
delete = "45m" # accepted and IGNORED -- the delete reads the create/update deadline
}
}
β οΈ Two traps in four lines.deletedoes nothing here, and a misspelled key (deletes) is discarded silently by Terraform's object-type conversion β no error, no effect.
14 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-data-platform-eastus"
location = "eastus"
}
module "key_vault" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-data-platform"
resource_group_name = module.rg.name
location = module.rg.location
tenant_id = var.tenant_id
# Azure SQL refuses a key from a vault without purge protection.
purge_protection_enabled = true
keys = {
sql-tde = {
key_type = "RSA"
key_size = 2048
key_opts = ["wrapKey", "unwrapKey"] # "get" is a VAULT permission, not a key operation
}
}
}
module "sql_server" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server.git?ref=v1.0.0"
name = "sql-platform-eastus"
resource_group_name = module.rg.name
location = module.rg.location
azuread_administrator = {
login_username = "sql-admins"
object_id = var.sql_admin_group_object_id
}
identity = { type = "SystemAssigned" }
# Deliberately NOT set here -- the protector is owned by the module below.
# transparent_data_encryption_key_vault_key_id = ...
}
module "tde_key_access" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.key_vault.id
role_assignments = {
sql_wrap_unwrap = {
principal_id = module.sql_server.identity_principal_id
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_type = "ServicePrincipal"
}
}
}
module "sql_tde" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"
server_id = module.sql_server.id
key_vault_key_id = module.key_vault.key_ids["sql-tde"]
auto_rotation_enabled = true
}
output "encryption" {
value = {
protector_id = module.sql_tde.id
key_kind = module.sql_tde.key_kind
key_version = module.sql_tde.key_version
customer_managed = module.sql_tde.this_server_uses_a_customer_managed_key
}
}π Every reference is a real output of a real sibling. The role assignment is what makes the apply succeed, and it is the piece most often missing.
Identity β server_id (required, force-new)
Key β key_vault_key_id, managed_hsm_key_id (mutually exclusive, both versioned-only)
Behaviour β auto_rotation_enabled
Tail β timeouts (no tags: the resource exposes none)
Full input schemas
variable "server_id" {
type = string
# Anchored to /subscriptions/<s>/resourceGroups/<rg>/providers/Microsoft.Sql/servers/<srv>
# A managed-instance ID is refused -- it has its own encryption-protector resource.
}
variable "key_vault_key_id" {
type = string
default = null
# Must be https://<vault>/keys/<name>/<version> -- the VERSION IS ALWAYS REQUIRED here.
# Conflicts with managed_hsm_key_id.
}
variable "managed_hsm_key_id" {
type = string
default = null
# Must be https://<hsm>.managedhsm.azure.net/keys/<name>/<version>.
# DEPRECATED on this resource; supplying a value warns at plan.
}
variable "auto_rotation_enabled" {
type = bool
default = false
# Passed through. Does NOT change which key identifiers are legal on this resource --
# but true plus a pinned version is the permanent-diff combination. See Architecture Notes.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string) # accepted and IGNORED
})
default = null
# Every value is checked as a Go duration, so "1h30m", "1.5h", "500ms" and a bare "0"
# are all accepted and a unitless "30" is refused at validate rather than at apply.
}| Output | Description | Notes |
|---|---|---|
id |
Resource ID of the protector. | Always ends /encryptionProtector/current. |
server_id |
The server this protector encrypts. | |
server_name, resource_group_name, subscription_id |
Parsed from the server ID. | |
key_vault_key_id |
The key identifier in effect. | Populated for an HSM key too. |
managed_hsm_key_id |
The HSM identifier as configured. | Read from the variable, so consumers are not made to print a deprecation warning. |
auto_rotation_enabled |
Whether the server follows the key as it rotates. | |
key_kind |
service_managed, key_vault or managed_hsm. |
|
this_server_uses_a_customer_managed_key |
Boolean form of the above. | |
vault_name, key_name, key_version |
Parsed from the configured key. | Null under a service-managed key. |
server_key_name, server_key_id |
The untracked server-key record the provider registers before repointing the protector. | Null under a service-managed key. Nothing removes these. |
rotation_setting_is_inert |
Rotation asked for with no customer key. | Conditional on the inputs. |
azure_rotates_past_the_pinned_version |
Rotation on with a pinned version β the permanent-diff combination. | Conditional on the inputs. |
the_vault_needs_soft_delete_and_purge_protection |
Constant true. | Azure validates both and fails setup if either is off. |
the_provider_creates_a_second_arm_record_it_does_not_track |
Constant true. | One per key version ever used. |
managed_hsm_key_id_must_really_point_at_a_managed_hsm |
Constant true, and unenforced. | A standard vault key passed here applies, then reads back as absent. |
a_server_level_key_stops_databases_choosing_a_service_managed_key |
Constant true. | The blast radius is every database on the server. |
the_sql_server_resource_owns_this_same_setting |
Constant true. | The overlap warning. |
setting_the_key_in_both_places_does_not_fail_cleanly |
Constant true. | What the overlap actually does. |
the_server_argument_cannot_express_hsm_or_auto_rotation |
Constant true. | Why this module exists alongside it. |
the_key_must_always_be_versioned_here |
Constant true. | The difference from the twin. |
auto_rotation_does_not_change_which_key_ids_are_legal_here |
Constant true. | |
the_empty_call_is_already_encrypted |
Constant true. | |
a_customer_managed_key_is_a_control_transfer_not_an_upgrade |
Constant true. | |
this_server_uses_a_customer_managed_key |
Conditional. | |
losing_the_key_makes_every_database_unreadable |
Constant true. | |
the_server_needs_wrap_and_unwrap_on_the_vault |
Constant true. | |
rotating_the_key_means_changing_this_configuration |
True for a customer-managed key with rotation off β the only state in which the version written here is the version in force. | Conditional on the inputs. |
destroying_this_resource_reverts_to_a_service_managed_key |
Constant true. | |
timeouts_delete_is_accepted_and_ignored |
Constant true. | |
creating_this_resource_overwrites_the_existing_protector |
Constant true. | |
the_two_key_arguments_are_mutually_exclusive |
Constant true. | |
state_may_show_both_key_fields_for_an_hsm_key |
Constant true. | |
managed_hsm_key_id_is_formally_deprecated_on_this_resource |
Constant true. | |
the_managed_instance_twin_is_not_deprecated |
Constant true. | The asymmetry. |
using_managed_hsm_key_id_emits_a_provider_warning |
Conditional. | |
this_resource_carries_a_state_upgrader |
Constant true. | |
one_protector_per_server |
Constant true. | |
force_new_fields |
["server_id"]. |
|
fields_that_can_change_after_creation |
The keys and the rotation flag. | |
fields_azure_returns_on_read |
Where drift is detectable. | |
no_secret_is_accepted_or_emitted_by_this_module |
Constant true. | |
this_resource_supports_no_azure_resource_tags |
Constant true. | |
an_empty_string_is_not_the_same_as_an_unset_key |
Constant true β why both key arguments are rendered through a normalizing local. |
π No output is sensitive, because none carries a secret. A key identifier is a reference, not key material.
The empty call is the safe call, and it is not the strong one. Transparent data encryption is on for every Azure SQL server whether or not this module sets a key. Supplying a customer-managed key does not add encryption β it changes who holds the key, and with it who can render every database on the server unreadable by deleting or disabling it. That is a control transfer and a responsibility. The module makes the caller type it, and says so plainly rather than presenting a customer key as a security upgrade.
The versioned-key rule is the module's most useful refusal, and mirroring it is not redundant. The provider validates both key arguments versioned-only with no dependency on anything else β but a provider schema rule fires against a literal value in a resource block, and a caller reaching this resource through a module never supplies one. Called this way, the provider's own check waits for plan. The mirror here fires during variable evaluation instead, so the failure arrives offline with no credentials, and the message can say why: it names the versionless case specifically, separates it from a wrong item type, and names the difference from the managed-instance twin, where the same value is legal under auto-rotation. Intra-family asymmetry is the norm in this provider, not the exception β and it is worth adding that the versioned-only rule is the provider's, not the service's: Azure SQL Database supports a versionless protector and resolves the latest enabled version itself.
auto_rotation_enabled is a pass-through whose default is kept for two reasons, and the second is the interesting one. This suite inverts a provider default when keeping it would make the feature the module is named for do nothing. Here the empty call is already encrypted, so there is nothing to rescue β that is the first reason. The second is that true is not the harmless setting it looks like on this resource. Azure's automated rotation, once on, moves the protector to the newest version of the key within 24 hours of it appearing; the provider requires a pinned version in the configuration and reads the live one back into state. The two are in direct tension, and the visible result is a plan that proposes rolling the key backwards on every run, for as long as the configuration and the vault disagree. The managed-instance twin resolves the same tension by accepting a versionless identifier and carrying a diff-suppression function for exactly this case β neither exists here. So false is kept because it is the quiet choice as well as the provider's, and the combination is reported through azure_rotates_past_the_pinned_version rather than refused, since the provider permits it and a caller may well want rotation more than a clean plan. Where the flag genuinely does nothing, with no customer key configured at all, rotation_setting_is_inert says so.
One apply, two ARM records, and only one of them tracked. Making a customer-managed key the protector is not a single write: the key is first registered on the server as a Microsoft.Sql/servers/keys record named <vault>_<key>_<version>, and only then is the protector repointed at that record by name. Terraform's state holds the protector alone. Nothing in the provider removes a registration β not switching keys, not terraform destroy, not the revert to a service-managed key β so a server accumulates one per key version it has ever used. That is defensible rather than a leak, because restoring an old backup needs the key version it was encrypted with, but it does mean the server's key list is not a picture of what is in use. server_key_id emits the record's ID for exactly that audit.
Lifecycle: only server_id is force-new, and destroy reverts. Changing the key or the rotation flag updates in place. Removing the resource issues a create-or-update that returns the server to a service-managed key β encryption is never turned off, and the databases stay readable. That is also why timeouts.delete is accepted and ignored: the delete path reads the create/update deadline, which matches what it actually does.
The overlap is the fact worth carrying away. azurerm_mssql_server writes the same encryption protector through its own optional argument. The two resources do not coordinate, and the failure mode is a permanent diff rather than an error: the server's read writes the live key into its own state unconditionally, and that argument is optional rather than computed. This module emits the overlap as a constant output because it is consequential, invisible in state, and inferable from nothing else in a plan.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Encryption at rest | On, with a Microsoft-managed key. There is no configuration that turns it off. | β |
| Key custody | Microsoft holds the key. | Supply key_vault_key_id or managed_hsm_key_id and accept the responsibility. |
| Key identifier precision | Versioned only, mirroring the provider. | None β the provider refuses the alternative. |
| Conflicting key sources | Refused at plan. | None β the provider declares them mutually exclusive. |
| Secrets | None accepted, none emitted. A key identifier is a reference. | β |
| Deprecated arguments | Reported, not hidden, and the module's own outputs avoid imposing the warning on callers. | β |
π
sensitive = truewould redact plan output and would not encrypt state β which is why this module carries no secret at all rather than marking one.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the source at a tag β ?ref=v1.0.0 β never a branch. This module is authored and verified plan-only; a human applies from CI.
What validate and fmt cover, with no credentials:
- Every input validation: the anchored server ID, the versioned-only key rules, and the mutual exclusion. All four fire offline.
- The provider's own schema validators, which run at the same stage.
- The deprecation warning on
managed_hsm_key_idβ supplying a literal value prints it at plan, which is how the asymmetry with the managed-instance twin was confirmed rather than assumed. - HCL syntax and formatting.
What only plan or apply reaches:
- Whether the server's identity actually has
get,wrapKeyandunwrapKeyon the vault. - Whether the vault has purge protection enabled.
- Whether the key exists and the version is current.
- Whether the SQL service can reach a network-restricted vault.
β οΈ A validation failure blocksterraform destroyas well as apply, which is why this module refuses only what the provider itself refuses and reports everything else.
Outputs:
encryption = {
"customer_managed" = true
"key_kind" = "key_vault"
"key_version" = "9f4c1e2ab7d34f80a1c6e5d2b8f70931"
"protector_id" = "/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-data-platform-eastus/providers/Microsoft.Sql/servers/sql-platform-eastus/encryptionProtector/current"
}
key_name = "sql-tde"
vault_name = "kv-data-platform"
rotation_setting_is_inert = false
| Symptom | Cause | Fix |
|---|---|---|
key_vault_key_id is a VERSIONLESS key identifier, and this resource requires the version⦠|
A versionless identifier. Legal on the managed-instance twin β and legal to Azure SQL Database itself β but not on this resource in this provider release. | Use the key's versioned id β module.key_vault.key_ids["β¦"], not key_versionless_ids. |
key_vault_key_id must be a Key Vault KEY identifier⦠|
Something that is not a key: a secrets/certificates identifier, an ARM Resource ID, or a path with too many segments. This is the shape check, which is separate from the version check so the two failures name themselves. |
Pass the key's own data-plane identifier, https://<vault>.vault.azure.net/keys/<name>/<version>. |
every timeouts value must be a Go duration string⦠|
A unitless number such as 30, or prose such as 30 minutes. |
Use a duration: 30m, 1h30m, 1.5h, 500ms, or a bare 0. |
both key_vault_key_id and managed_hsm_key_id are set⦠|
The provider declares them mutually exclusive. | Keep the one matching where the key lives. To use an HSM key without the deprecation warning, pass it through key_vault_key_id. |
server_id must be an Azure SQL SERVER Resource ID⦠|
A managed-instance ID. | Use azurerm_mssql_managed_instance_transparent_data_encryption instead β it is a different resource with different rules. |
Warning: Argument is deprecated on every validate |
managed_hsm_key_id carries the schema's deprecation flag. |
Expected. Pass the same identifier through key_vault_key_id to remove it β that argument already accepts an HSM key. |
The SQL server plans a change to its transparent_data_encryption_key_vault_key_id on every run |
Both the server resource and this module own the protector. | Leave the server's argument unset wherever this module is in play. |
| Apply fails with a key-access or wrap error at the service | The server's identity lacks get/wrapKey/unwrapKey, or the server has no managed identity at all. |
Give the server an identity, then grant Key Vault Crypto Service Encryption User to module.sql_server.identity_principal_id at the vault. |
| Apply is refused as soon as the key is supplied, before any permission is even checked | Azure SQL validates that the vault has soft delete AND purge protection, and fails protector setup if either is off. | Enable both on the vault and apply again. Retrying without changing the vault will fail identically. |
| The key was rotated in the vault but the server still uses the old version | auto_rotation_enabled is false, so nothing watches the vault and the version here is the version in force. |
Either update key_vault_key_id to the new versioned identifier and apply, or set auto_rotation_enabled = true β but read the next row first. |
Every plan proposes changing key_vault_key_id back to an older version, and applying it really does roll the key back |
auto_rotation_enabled = true while the provider requires a pinned version. Azure moved the protector forward; Terraform is proposing to undo it. |
Expected on this resource, and reported as azure_rotates_past_the_pinned_version. Either update the pinned version to what Azure chose, or leave rotation off and rotate deliberately. There is no diff-suppression here as there is on the managed-instance twin. |
| The server's key list has grown far beyond the keys in use | Each key version ever made the protector left a Microsoft.Sql/servers/keys registration behind, and nothing removes them. |
Expected β see server_key_id. Old registrations are what a restore of an old backup needs, so audit before pruning. |
terraform destroy leaves encryption apparently unchanged |
Destroy reverts to a service-managed key rather than deleting anything. | Expected. Encryption is never switched off. |
A timeouts.delete value has no effect |
The delete function reads the create/update deadline. | Set timeouts.create instead. |
azurerm_mssql_server_transparent_data_encryptionazurerm_mssql_serverβ note its owntransparent_data_encryption_key_vault_key_idargumentazurerm_mssql_managed_instance_transparent_data_encryptionβ the twin, with different version rules- Azure SQL transparent data encryption with customer-managed keys
- Sibling modules:
terraform-azurerm-mssql-server,terraform-azurerm-key-vault,terraform-azurerm-key-vault-managed-hardware-security-module-key,terraform-azurerm-role-assignments - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."