Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Managed Redis Terraform Module

Creates a Managed Redis instance and its default database, closed to the public internet, with the durability and persistence trade-offs made explicit. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • βš™οΈ Creates one azurerm_managed_redis, named this β€” the Redis Enterprise cluster and its default database in a single resource.
  • πŸ”’ public_network_access defaults to Disabled, against the provider's Enabled. Note it is a string enum, not the boolean this suite's terraform-azurerm-redis-cache module uses for the same idea.
  • πŸ”΄ Three fields destroy all data when changed, and the plan calls it an in-place update β€” clustering_policy, geo_replication_group_name and module. No replacement marker, no warning.
  • πŸ”‘ Plan access is credential access. The default database's two access keys are computed, schema-sensitive attributes Terraform must read, so they are in state. This module deliberately emits neither.
  • 🧬 default_database is grouped because three documented rules span its fields β€” and the provider enforces only two of them.
  • ⚠️ This is Microsoft.Cache/redisEnterprise, a different ARM type from Azure Cache for Redis. There is no migration path between them and the SKU vocabularies are disjoint.

πŸ’‘ Why it matters: almost everything expensive about this resource is a create-time decision. Two of its enums silently wipe the dataset on change, a downgrade may become a replacement, and the two durability mechanisms are mutually exclusive.


❀️ Support this project

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


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

This DAG is shared with the geo-replication sibling, validated once per focus. What differs is which node is highlighted and what each node's text carries: this one names the grouped default_database and the customer-managed-key wiring, while the geo-replication focus carries the group-name rule and the collision warning.

flowchart TB
  RG["terraform-azurerm-resource-group"]
  MR["terraform-azurerm-managed-redis -- the cluster AND its default database in ONE resource"]
  GEO["terraform-azurerm-managed-redis-geo-replication -- declare it ONCE per group; linking is reciprocal"]
  APA["terraform-azurerm-managed-redis-access-policy-assignment -- grants one Entra ID principal data-plane access, and is HOW you use the instance once access keys are off"]
  PE["terraform-azurerm-private-endpoint -- required to reach the instance, because public_network_access defaults to Disabled here"]
  KV["terraform-azurerm-key-vault -- holds the customer-managed key, which this module NAMES and never reads"]
  UAI["terraform-azurerm-user-assigned-identity -- must be attached to the instance AND named in the key block"]
  RC["terraform-azurerm-redis-cache -- a DIFFERENT ARM type: Microsoft.Cache/redis, not redisEnterprise. No migration path"]
  PEER["other terraform-azurerm-managed-redis instances in other regions -- up to 4 more, all sharing one group name"]

  RG -->|"name as resource_group_name"| MR
  MR -->|"id as managed_redis_id"| GEO
  MR -->|"id as the parent for a policy assignment"| APA
  MR -->|"id as private_service_connection target"| PE
  KV -->|"key_ids entry as customer_managed_key.key_vault_key_id"| MR
  UAI -->|"id as BOTH identity.identity_ids and customer_managed_key.user_assigned_identity_id"| MR
  PEER -->|"id as linked_managed_redis_ids"| GEO
  RC -->|"no migration: different ARM type, disjoint SKU vocabulary"| MR

  style MR fill:#0078D4,stroke:#004578,color:#ffffff
  style RG fill:#004578,stroke:#00335c,color:#ffffff
  style GEO fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style APA fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style PE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style KV fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style UAI fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style RC fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style PEER fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

🧬 What this module builds

flowchart TB
  IN_ID["name plus resource_group_name plus location -- regionally unique name, and Managed Redis is not in every region"]
  IN_SKU["sku_name -- a CLOSED set of 45 values across four families. A downgrade may silently become a replacement"]
  IN_NET["public_network_access -- flipped to Disabled. A STRING enum, not the boolean redis-cache uses"]
  IN_HA["high_availability_enabled -- the provider already defaults it to true, so NO flip applies"]
  IN_DB["default_database -- grouped because THREE documented rules span its fields: the two persistence methods conflict, both conflict with geo-replication, and only RediSearch and RedisJSON work with geo-replication"]
  IN_CMK["customer_managed_key -- both fields required, and the identity it names must ALSO be attached below"]
  IN_MI["identity -- defaults to null; a cache is reached BY clients"]
  THIS["azurerm_managed_redis.this"]
  ARM["Microsoft.Cache/redisEnterprise -- the cluster AND its default database, which the provider also exposes as two older separate resource types"]
  OUT_ID["id then name then hostname"]
  OUT_EFF["effective settings with provider defaults filled in: client_protocol, clustering_policy, eviction_policy, access_keys_authentication_enabled"]
  OUT_DUR["persistence_enabled, uses_append_only_file_persistence, uses_redis_database_persistence, has_no_durability_beyond_the_instance"]
  OUT_FLAG["plan_access_is_credential_access, changing_these_database_fields_destroys_all_data_without_a_replacement_in_the_plan, a_sku_downgrade_may_become_a_replacement_or_fail, this_is_a_different_arm_type_from_redis_cache"]

  IN_ID --> THIS
  IN_SKU --> THIS
  IN_NET --> THIS
  IN_HA --> THIS
  IN_DB --> THIS
  IN_CMK --> THIS
  IN_MI --> THIS
  THIS -->|"writes"| ARM
  THIS --> OUT_ID
  THIS --> OUT_EFF
  THIS --> OUT_DUR
  THIS --> OUT_FLAG

  style THIS fill:#0078D4,stroke:#004578,color:#ffffff
  style ARM fill:#004578,stroke:#00335c,color:#ffffff
  style IN_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_SKU fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_NET fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_HA fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_DB fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_CMK fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_MI fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_EFF fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_DUR fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_FLAG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

Resource inventory

Resource Count Notes
azurerm_managed_redis.this 1 The keystone β€” cluster and default database.
default_database block 0–1 Grouped into one variable; three documented rules span its fields.
default_database.module block 0–4 A bounded nested collection.
customer_managed_key block 0–1 Both fields required; the identity must also be attached.
identity block 0–1 Defaults to none.

βœ… Provider / Versions

Item Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None. The caller configures the provider, its authentication, and its mandatory features {} block.
Module type standalone
Keystone azurerm_managed_redis.this

Schema notes that bite

  • πŸ”΄ clustering_policy, geo_replication_group_name and module destroy the database when changed. The provider: "Updating the following properties will force a new database to be created, data will be lost and Managed Redis will be unavailable during the operation." The plan shows an in-place update β€” no -/+, no warning.
  • πŸ”΄ A SKU downgrade is one of three things and which is not knowable at plan time: an in-place resize, a destroy-and-create, or an apply-time error. The provider's own validation is "on a best-effort basis".
  • πŸ”΄ The two access keys are computed and schema-sensitive, so refreshing this resource reads them and state holds them. Anyone who can plan can obtain them.
  • πŸ”΄ The two persistence methods conflict with each other AND with geo-replication. Persistence and geo-replication are alternative durability strategies here, not complementary ones.
  • πŸ”΄ Only RediSearch and RedisJSON may be combined with geo-replication β€” and the provider declares no ConflictsWith for this one, so nothing but this module checks it.
  • ⚠️ public_network_access is a STRING (Enabled/Disabled), where terraform-azurerm-redis-cache uses a boolean public_network_access_enabled. Passing false here is a type error, which is the safe direction.
  • ⚠️ sku_name is a closed set of 45 values whose size letter differs per family (B, X, A, M) and does not match the family's initial.
  • ⚠️ RediSearch has a lower-case i and a capital S where the other three module names are plain Pascal case, so the set cannot be normalised.
  • ⚠️ persistence_append_only_file_backup_frequency has exactly one legal value, 1s β€” a single-member enum.
  • βœ… tags are freely updatable here, unlike several resources elsewhere in this suite where a tag change forces replacement. Confirmed from the absence of a force-new note in the provider's documentation.

πŸ”‘ Required Azure RBAC Roles / Permissions

Principal Permission Scope Why
The Terraform identity Microsoft.Cache/redisEnterprise/write The resource group Create and update the instance.
The Terraform identity Microsoft.Cache/redisEnterprise/read The instance Refresh and plan.
The Terraform identity Microsoft.Cache/redisEnterprise/delete The instance Destroy.
The Terraform identity Microsoft.Cache/redisEnterprise/databases/listKeys/action The instance Implied by planning. The access keys are computed attributes the provider reads.
The Terraform identity Microsoft.ManagedIdentity/userAssignedIdentities/assign/action Each identity Only when identity is used.
The instance's identity Key Vault Crypto Service Encryption User The Key Vault key Only when customer_managed_key is used.

πŸ”΄ Plan rights are key rights here. Because the access keys are computed, sensitive attributes, whoever can refresh this resource can read them β€” and whoever can read the state file already has them. That belongs in an access review, not in a discovery after the fact. plan_access_is_credential_access says so as an output.

πŸ’‘ The way out is to remove the key, not to guard it better. access_keys_authentication_enabled already defaults to false; grant principals with terraform-azurerm-managed-redis-access-policy-assignment and there is no shared secret.


Azure Prerequisites

  • Microsoft.Cache registered on the subscription.
  • A region that offers Managed Redis. The provider points at the product availability table rather than publishing a list, so this is a moving set and is reported rather than enforced.
  • A private endpoint, given this module's public_network_access = "Disabled" default. Until one exists the instance is unreachable from anywhere β€” see a_private_endpoint_is_not_created_here.
  • Balanced_B3 or higher if the instance will join a geo-replication group.
  • A Key Vault key and a user-assigned identity with access to it, if using a customer-managed key. The identity must be attached to the instance and named in the key block; this module checks both.

πŸ“ Module Structure

terraform-azurerm-managed-redis/
β”œβ”€β”€ providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0. No provider block.
β”œβ”€β”€ variables.tf     # 11 typed inputs, 31 validations.
β”œβ”€β”€ main.tf          # One keystone `this`; dynamic default_database / module / customer_managed_key / identity / timeouts.
β”œβ”€β”€ outputs.tf       # id first, then name and hostname, then effective settings and the invisible-consequence constants.
β”œβ”€β”€ README.md        # This document.
β”œβ”€β”€ SCOPE.md         # The cross-module contract.
β”œβ”€β”€ LICENSE          # MIT.
└── .gitignore       # The canonical library ignore file.

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

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

  name                = "redis-orders-prod"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B3"
}

That produces an instance closed to the internet, with TLS-only client access, access-key auth off, high availability on, and no durability beyond the instance itself. Read examples 3 and 4 before treating it as anything but a cache.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
resource_group_name Resource group name terraform-azurerm-resource-group β†’ name (not id)
customer_managed_key.key_vault_key_id Versioned Key Vault key URL terraform-azurerm-key-vault β†’ key_ids["<name>"]
customer_managed_key.user_assigned_identity_id Identity Resource ID terraform-azurerm-user-assigned-identity β†’ id
identity.identity_ids Identity Resource IDs terraform-azurerm-user-assigned-identity β†’ id

Emits

Output Description Consumed by
id Instance Resource ID terraform-azurerm-managed-redis-geo-replication, terraform-azurerm-private-endpoint
name / hostname Connection target Application configuration
geo_replication_group_name The group name to share with every member The geo-replication sibling's members
identity_principal_id Managed identity principal ID terraform-azurerm-role-assignments
has_no_durability_beyond_the_instance Whether the dataset is memory-only Compliance check blocks
plan_access_is_credential_access Constant true Access reviews

πŸ“š Example Library

1 Β· The minimum call, and the three things it leaves at their defaults
module "cache_minimum" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-prod"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B3"
}

πŸ”’ Four arguments, and the closed posture comes for free: public_network_access = "Disabled" is this module's default, and the provider's own defaults already give TLS-only client access and access-key auth off.

⚠️ has_no_durability_beyond_the_instance is true here. Neither persistence nor geo-replication is on, so the dataset lives only in this instance's memory.

⚠️ And it is unreachable until a private endpoint exists. See example 11.

2 Β· Accepting the provider's public-access default
module "cache_public" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                  = "redis-public"
  resource_group_name   = module.cache_rg.name
  location              = "eastus2"
  sku_name              = "Balanced_B3"
  public_network_access = "Enabled"
}

⚠️ You have to type this to get it. A cache holds application data and has no business being publicly reachable by default, so the module's default is Disabled and diverges_from_the_provider_default_public_network_access reports which way the call went.

πŸ”΄ Mind the type. This is a string enum. This suite's terraform-azurerm-redis-cache module takes a boolean public_network_access_enabled for the same idea, so a value copied between the two is a type error rather than a silent misconfiguration β€” the safe direction, but worth knowing. public_network_access_enabled is also emitted here as a boolean so a composition can assert uniformly across both.

3 Β· RDB snapshot persistence
module "cache_rdb" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-prod"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B5"

  default_database = {
    persistence_redis_database_backup_frequency = "6h"
  }
}

ℹ️ Supplying the frequency is what turns persistence on β€” there is no separate boolean. Legal values are "1h", "6h" and "12h".

πŸ”΄ This cannot be combined with geo-replication. The provider documents that persistence works only on non-geo-replicated databases, so the two are mutually exclusive and the module rejects the combination at plan time. Pick the guarantee you actually need: point-in-time recovery in one region, or cross-region redundancy.

4 Β· AOF persistence, and its single legal value
module "cache_aof" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-ledger"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B5"

  default_database = {
    persistence_append_only_file_backup_frequency = "1s"
  }
}

⚠️ "1s" is the only legal value β€” a single-member enum, not a range. Anything else is rejected with that stated.

πŸ’‘ AOF loses at most a second; RDB loses up to the snapshot interval. uses_append_only_file_persistence and uses_redis_database_persistence are separate outputs precisely so a reviewer can see which guarantee is in force rather than just that "persistence is on".

πŸ”΄ Setting both is rejected. The provider allows only one persistence method.

5 Β· Redis modules, and the casing trap
module "cache_modules" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-search"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "MemoryOptimized_M50"

  default_database = {
    modules = [
      { name = "RediSearch" },
      { name = "RedisJSON" },
      { name = "RedisBloom", args = "ERROR_RATE 0.01 INITIAL_SIZE 400" },
    ]
  }
}

⚠️ RediSearch has a lower-case i and a capital S. The other three β€” RedisBloom, RedisTimeSeries, RedisJSON β€” are plain Pascal case. There is no normalisation rule that fixes one without breaking the others, and the module's error message says exactly that. RedisSearch is the mistake it catches.

πŸ”΄ At most four modules, which is the provider's schema bound β€” and since there are only four legal names, the bound is reached only by listing every one.

πŸ”΄ Adding, removing or changing a module destroys the database. The plan will show an in-place update.

6 Β· Preparing an instance for geo-replication
locals {
  redis_geo_group = "orders-global"
}

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

  name                = "redis-orders-eastus2"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B3"

  default_database = {
    geo_replication_group_name = local.redis_geo_group

    # Only RediSearch and RedisJSON are permitted alongside geo-replication.
    modules = [{ name = "RedisJSON" }]
  }
}

πŸ”΄ Balanced_B3 or higher is required for geo-replication. The module rejects Balanced_B0 and Balanced_B1 alongside a group name, because those two are demonstrably below the floor β€” but what "or higher" means across the ComputeOptimized, FlashOptimized and MemoryOptimized families is not published, so no ordering is invented for them. See geo_replication_sku_floor_is_only_partly_checkable.

πŸ”΄ Only RediSearch and RedisJSON work with geo-replication, and the provider declares no conflict for that rule β€” this module is the only thing that catches RedisBloom or RedisTimeSeries here.

⚠️ Setting the group name alone replicates nothing. It creates a group whose only member is this database. The terraform-azurerm-managed-redis-geo-replication module is what links members, and every member must carry the same name β€” derive it from one local, as above, because nothing can check that the strings match.

7 Β· A customer-managed key, and the identity it needs
module "cache_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-regulated"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B5"

  identity = {
    type         = "UserAssigned"
    identity_ids = [module.cache_identity.id]
  }

  customer_managed_key = {
    key_vault_key_id          = module.cache_key_vault.key_ids["redis-cmk"]
    user_assigned_identity_id = module.cache_identity.id
  }
}

πŸ”΄ Naming an identity in the key block does not attach it. The module checks that identity includes UserAssigned and that its identity_ids contains the same Resource ID β€” both halves are inputs, so this is one of the few cross-argument rules in this family that can be enforced rather than reported.

⚠️ The key URL must be versioned. key_ids["redis-cmk"] is; key_versionless_ids["redis-cmk"] is not, and is rejected with that named.

πŸ”’ This module names a key and never reads one. No key material passes through Terraform, so nothing here needs marking sensitive.

8 Β· Turning access keys back on
module "cache_keys" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-legacy-client"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B3"

  default_database = {
    access_keys_authentication_enabled = true
  }
}

βœ… No flip was needed here, and that is the finding. The provider already defaults access_keys_authentication_enabled to false, so the hardened state arrives without this module doing anything. Worth stating because this suite's terraform-azurerm-redis-cache module did have to flip its equivalent β€” same idea, different service, different provider default. Decided by reading each default rather than carrying the sibling's choice across.

πŸ”΄ The keys exist regardless of this flag, as computed attributes Terraform reads. Setting it true only makes them usable for authentication. Either way, plan access is key access.

9 Β· The three fields that destroy data
module "cache_create_time_decisions" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-prod"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B5"

  default_database = {
    # All three of these are create-time decisions. Changing any of them later destroys the dataset,
    # and `terraform plan` shows an ordinary in-place update with no warning.
    clustering_policy          = "OSSCluster"
    geo_replication_group_name = "orders-global"
    modules                    = [{ name = "RedisJSON" }]
  }
}

πŸ”΄ The provider is explicit: updating clustering_policy, geo_replication_group_name or module "will force a new database to be created, data will be lost and Managed Redis will be unavailable during the operation."

πŸ”΄ The plan will not protect you. The recreation happens to the database inside a resource Terraform is updating in place β€” so there is no -/+, no replacement marker, and no warning of any kind. The apply succeeds and the dataset is gone. changing_these_database_fields_destroys_all_data_without_a_replacement_in_the_plan is emitted for exactly this reason.

10 Β· Scaling, and why down is not the reverse of up
module "cache_scaled" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-prod"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"

  # Scaling UP is an ordinary in-place update. Scaling DOWN is one of three things.
  sku_name = "MemoryOptimized_M100"
}

πŸ”΄ A downgrade may become a replacement, or may just fail. The provider: a lower tier "is restricted by Azure under certain conditions, in which case the resource will be marked for recreation", and its validation "is on a best-effort basis, if the provider is unable to determine whether it can change the SKU in-place, it will attempt to do regardless and this request may fail." Scale up freely; treat scaling down as a change that needs a window.

⚠️ The size letter differs per family and does not match the family's initial β€” Balanced_B*, ComputeOptimized_X*, FlashOptimized_A*, MemoryOptimized_M*. And there is no Basic/Standard/Premium here; those belong to azurerm_redis_cache, which the error message names.

11 Β· Reaching a closed instance
module "cache_private" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-prod"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B3"
  # public_network_access defaults to "Disabled".
}

module "cache_endpoint" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-endpoint.git?ref=v1.0.0"

  name                = "pe-redis-orders"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  subnet_id           = module.cache_vnet.subnet_ids["snet-data"]

  private_service_connection = {
    name                           = "psc-redis-orders"
    private_connection_resource_id = module.cache_private.id
    subresource_names              = ["redisEnterprise"]
    is_manual_connection           = false
  }
}

πŸ”΄ A closed instance with no private endpoint is secure and useless. This module does not create the endpoint β€” a_private_endpoint_is_not_created_here says so as a constant, because "unreachable" is easy to mistake for "misconfigured" when an application first fails to connect.

12 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

data "azurerm_client_config" "current" {}

locals {
  redis_geo_group = "orders-global"
}

module "cache_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-cache-prod"
  location = "eastus2"

  tags = {
    environment = "prod"
    workload    = "cache"
  }
}

module "cache_vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-cache-prod"
  resource_group_name = module.cache_rg.name
  location            = module.cache_rg.location
  address_space       = ["10.60.0.0/16"]

  subnets = {
    "snet-data" = {
      address_prefixes = ["10.60.1.0/24"]
    }
  }
}

module "cache_identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "uai-redis-cmk"
  resource_group_name = module.cache_rg.name
  location            = module.cache_rg.location
}

module "cache_key_vault" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  name                = "kv-cache-prod-01"
  resource_group_name = module.cache_rg.name
  location            = module.cache_rg.location
  tenant_id           = data.azurerm_client_config.current.tenant_id
}

# The primary member of the geo-replication group.
module "cache_eastus2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-eastus2"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B5"

  identity = {
    type         = "UserAssigned"
    identity_ids = [module.cache_identity.id]
  }

  customer_managed_key = {
    key_vault_key_id          = module.cache_key_vault.key_ids["redis-cmk"]
    user_assigned_identity_id = module.cache_identity.id
  }

  default_database = {
    # The SAME group name on every member. Nothing can check that the strings match, so it comes
    # from one local. Persistence is deliberately absent -- it cannot coexist with geo-replication.
    geo_replication_group_name = local.redis_geo_group
    modules                    = [{ name = "RedisJSON" }]
  }

  tags = {
    environment = "prod"
  }
}

# The secondary member, in another region, with the same group name and SKU floor.
module "cache_westus2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-westus2"
  resource_group_name = module.cache_rg.name
  location            = "westus2"
  sku_name            = "Balanced_B5"

  default_database = {
    geo_replication_group_name = local.redis_geo_group
    modules                    = [{ name = "RedisJSON" }]
  }

  tags = {
    environment = "prod"
  }
}

# ONE declaration links the whole group -- linking is reciprocal.
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

module "cache_endpoint" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-endpoint.git?ref=v1.0.0"

  name                = "pe-redis-orders"
  resource_group_name = module.cache_rg.name
  location            = module.cache_rg.location
  subnet_id           = module.cache_vnet.subnet_ids["snet-data"]

  private_service_connection = {
    name                           = "psc-redis-orders"
    private_connection_resource_id = module.cache_eastus2.id
    subresource_names              = ["redisEnterprise"]
    is_manual_connection           = false
  }
}

# The composition's own output carries what no single member can enforce.
output "cache_posture" {
  value = {
    # Both members are in the group, but only the composition knows the names match.
    geo_group_wired = [
      module.cache_eastus2.geo_replication_group_name,
      module.cache_westus2.geo_replication_group_name,
    ]

    # Geo-replication is this group's durability story; persistence is off by necessity.
    durability_is_cross_region = module.cache_eastus2.is_geo_replication_member
    memory_only                = module.cache_eastus2.has_no_durability_beyond_the_instance

    # Plan rights are key rights on every member.
    plan_is_credential_access = module.cache_eastus2.plan_access_is_credential_access

    # And the group is one declaration, not two.
    group_size = module.cache_geo.group_size
  }
}

check "cache_is_not_public" {
  assert {
    condition     = module.cache_eastus2.public_network_access_enabled == false
    error_message = "redis-orders-eastus2 is reachable from the public internet."
  }

  assert {
    condition     = module.cache_eastus2.traffic_is_encrypted_in_transit
    error_message = "redis-orders-eastus2 accepts plaintext client connections."
  }
}

πŸ’‘ Order comes from references alone β€” the instances from the resource group, the link from both instance ids, the endpoint from the primary. No depends_on anywhere.

πŸ”΄ The geo-replication module is declared ONCE, not once per member. Linking is reciprocal, and its id is the anchor's id β€” so a second declaration would collide silently and produce a permanent alternating diff.

⚠️ module.cache_rg is referenced for name and location only. The resource-group module takes tags as an input and emits none, so tags are declared per module.


πŸ“₯ Inputs

Required (4) β€” name, resource_group_name, location, sku_name.

Posture (2) β€” public_network_access (default "Disabled", flipped), high_availability_enabled (provider's true stands β€” no flip applies).

Payload (1) β€” default_database, grouped around three documented spanning rules.

Encryption and identity (2) β€” customer_managed_key, identity.

Universal tail (2) β€” tags (freely updatable here), timeouts (all four keys).

Full input schemas
Name Type Default Required
name string β€” βœ…
resource_group_name string β€” βœ…
location string β€” βœ…
sku_name string β€” βœ…
high_availability_enabled bool null β†’ provider true β€”
public_network_access string "Disabled" β€”
default_database see below null β€”
customer_managed_key object({ key_vault_key_id = string, user_assigned_identity_id = string }) null β€”
identity object({ type = string, identity_ids = optional(set(string)) }) null β€”
tags map(string) {} β€”
timeouts object({ create, read, update, delete }), all optional strings null β€”
default_database = {
  access_keys_authentication_enabled = optional(bool)   # provider default false -- already hardened
  client_protocol                    = optional(string) # "Encrypted" | "Plaintext"; default "Encrypted"
  clustering_policy                  = optional(string) # "EnterpriseCluster" | "OSSCluster" | "NoCluster"
  eviction_policy                    = optional(string) # one of eight values; default "VolatileLRU"
  geo_replication_group_name         = optional(string) # must match on every group member

  persistence_append_only_file_backup_frequency = optional(string) # only "1s"
  persistence_redis_database_backup_frequency   = optional(string) # "1h" | "6h" | "12h"

  modules = optional(list(object({                # at most 4
    name = string                                 # RedisBloom | RedisTimeSeries | RediSearch | RedisJSON
    args = optional(string)
  })), [])
}

31 validations, all proven to fire. Notably: the closed 45-value SKU set with a separate wrong-case message; the partly-checkable geo-replication SKU floor; the two persistence conflicts; the geo-replication module restriction the provider does not enforce; and the cross-block check that a customer-managed key's identity is actually attached.


🧾 Outputs

Output Description Notes
id Instance Resource ID Emitted first; Microsoft.Cache/redisEnterprise
name / resource_group_name / location Identity and placement
hostname Connection target Computed; deliberately not sensitive
sku_name The SKU created
high_availability_enabled Effective value, provider default filled in
public_network_access The string enum Matches the provider
public_network_access_enabled The same fact as a boolean For uniform assertions across this suite
diverges_from_the_provider_default_public_network_access Whether the closed default is in force
access_keys_authentication_enabled / client_protocol / clustering_policy / eviction_policy Effective values Provider defaults filled in
traffic_is_encrypted_in_transit Named for the consequence Compliance check blocks
geo_replication_group_name / is_geo_replication_member Group membership Wire to every member
persistence_enabled / uses_append_only_file_persistence / uses_redis_database_persistence Split by mechanism Different guarantees
has_no_durability_beyond_the_instance The default state, named for its consequence
redis_module_names / redis_module_count Modules loaded Out of a maximum of four
uses_customer_managed_key Whether a CMK is in force
identity_principal_id / identity_tenant_id / has_identity / identity_type / user_assigned_identity_count Identity facts A count, not the IDs
plan_access_is_credential_access Constant true Read this one
access_keys_are_not_emitted_by_this_module Constant true Deliberate
entra_id_authentication_removes_the_key_entirely Constant true The stronger position
changing_these_database_fields_destroys_all_data_without_a_replacement_in_the_plan Constant true The sharpest trap
a_sku_downgrade_may_become_a_replacement_or_fail Constant true Three possible outcomes
geo_replication_sku_floor_is_only_partly_checkable Constant true Two SKUs rejected, no ordering invented
geo_replication_group_names_must_match_and_nothing_here_can_check_it Constant true A bare-string join
this_is_a_different_arm_type_from_redis_cache Constant true No migration path
name_must_be_regionally_unique / region_availability_is_not_checked_here Constants Reported, not enforced
a_private_endpoint_is_not_created_here Constant true Closed and unreachable is the default
tags_are_freely_updatable_on_this_resource Constant true Unlike several siblings

Neither access key is emitted. They are in state β€” that is unavoidable, because the provider marks them computed β€” but re-emitting them would copy them into every consuming configuration's state as well.


🧠 Architecture Notes

The plan is not a safety net on this resource, and that is the single most important thing to know. Three default_database fields β€” clustering_policy, geo_replication_group_name and module β€” destroy the dataset when changed, and because the recreation happens to the database inside a resource Terraform updates in place, the plan shows an ordinary in-place update with no replacement marker and no warning. A SKU downgrade is similarly opaque: it may resize in place, may be silently converted to a replacement, or may just fail at apply, and the provider's own validation is "best-effort". Both facts are emitted as constants because no amount of reading the plan reveals them.

default_database is grouped because three documented rules span its fields. The two persistence frequencies conflict with each other; both conflict with geo_replication_group_name; and only RediSearch and RedisJSON may be combined with geo-replication. The provider declares ConflictsWith for the first two and nothing at all for the third, so this module is the only thing that catches a RedisBloom in a geo-replicated group. That third rule is also why the grouping had to include modules rather than leaving it as a separate variable.

Two provider defaults here are already the hardened ones, and neither is flipped. access_keys_authentication_enabled defaults to false and client_protocol to Encrypted. That is worth stating because this suite's terraform-azurerm-redis-cache module did have to flip its equivalents β€” the same ideas, a different service, different provider defaults. The one flip this module does make is public_network_access, from Enabled to Disabled. And high_availability_enabled is a third case again: the provider's true is already the stronger value, so there is nothing to flip and the module reports the effective setting instead.

Plan access is credential access. The default database's primary_access_key and secondary_access_key are computed attributes the provider marks sensitive, so Terraform must read them to refresh and they are written to state. Anyone who can plan can obtain them. The module emits neither β€” re-emitting a secret copies it into every consumer's state for no benefit β€” and points at the configuration that removes the secret rather than guarding it: access keys off, Entra ID on.

Durability is an either/or, and "neither" is the default. Persistence and geo-replication are mutually exclusive here, so has_no_durability_beyond_the_instance is true on the empty call and a reader has to choose deliberately between point-in-time recovery in one region and cross-region redundancy. The two persistence flags are split by mechanism because AOF and RDB give materially different guarantees.

A different ARM type from Azure Cache for Redis. This is Microsoft.Cache/redisEnterprise, confirmed from the resource's own import path; azurerm_redis_cache is Microsoft.Cache/redis. Not two versions of one thing β€” two services, with disjoint SKU vocabularies and no in-place migration. The provider also ships azurerm_redis_enterprise_cluster and azurerm_redis_enterprise_database, an older split surface over what appears to be the same ARM objects this single resource covers; managing one cluster through both surfaces would give two Terraform resource types one ARM object with no shared identity to conflict on.

Segment arithmetic and null-safety. try(obj.attr, "") returns null, not the fallback, when the object exists and the attribute is unset β€” try catches errors, and reading a null optional() attribute is not one. So every trimspace() on an optional attribute is written as try(trimspace(x), "") rather than trimspace(try(x, "")).


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Public reachability public_network_access = "Disabled" β€” flipped from the provider's Enabled "Enabled"
Access-key authentication false, and the provider already defaults it there β€” no flip needed true
Client protocol Encrypted, again the provider's own default "Plaintext"
High availability true, the provider's default and already the stronger value β€” no flip applies false
Access keys in outputs Never emitted, though they are in state β€”
Key material The module names a Key Vault key and never reads one β€”
A CMK identity that is not attached Rejected at plan time β€”
Half-configured encryption Impossible β€” both CMK fields required together β€”
Persistence + geo-replication Rejected, per the provider's documented conflict β€”
A module unsupported in a group Rejected, though the provider enforces nothing β€”
Durability Not defaulted. The two mechanisms are mutually exclusive, so "neither" is reported rather than silently chosen β€”

⚠️ Two things this table cannot cover. The instance's reachability depends on a private endpoint this module does not create, and its data safety depends on decisions that are irreversible after creation. Both are emitted as constants instead.


πŸš€ Runbook

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

Pin the module by tag, never by branch:

source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

Authored and verified plan-only. No cloud apply happens during authoring or review; a human applies from CI.


πŸ§ͺ Testing

Gate What it proves What it does not
terraform init -backend=false The provider constraint resolves Nothing about Azure
terraform validate HCL parses; types line up; validations are declared It does not run root-module variable validations from a calling configuration
terraform fmt -check Canonical formatting Nothing semantic
terraform console with a .tfvars file The real offline harness. All 31 validations proven to fire, by line number Nothing needing an Azure API call
terraform plan Provider-level validation and the ConflictsWith declarations Requires credentials; not run here
Apply Region availability, name uniqueness, and whether a SKU change resizes or replaces β€”

All 31 validations were proven individually by line number β€” Terraform hard-wraps messages, so grepping for a phrase silently matches nothing. Fixtures were designed from each guard: the closed-set and wrong-case SKU checks necessarily overlap for a case variant, so a separate fixture (Balanced_B7) proves the closed-set check alone.

Every derived local was printed against six shapes β€” minimum, fully populated, geo-replicated, AOF-persistent, customer-managed-key, and system-assigned identity. That check found the only real defect in this module, and it was a new variant of a trap this library has hit before: trimspace(try(var.default_database.geo_replication_group_name, "")) failed with "argument must not be null" on every fixture that set default_database at all. try catches errors, and reading a declared-but-unset optional() attribute is not an error β€” it yields null, which trimspace then rejects. Rewritten as try(trimspace(x), "") in seven places.


πŸ’¬ Example Output

Outputs:

access_keys_are_not_emitted_by_this_module = true
access_keys_authentication_enabled = false
a_private_endpoint_is_not_created_here = true
a_sku_downgrade_may_become_a_replacement_or_fail = true
changing_these_database_fields_destroys_all_data_without_a_replacement_in_the_plan = true
client_protocol = "Encrypted"
clustering_policy = "OSSCluster"
diverges_from_the_provider_default_public_network_access = true
entra_id_authentication_removes_the_key_entirely = true
eviction_policy = "VolatileLRU"
geo_replication_group_name = "orders-global"
geo_replication_group_names_must_match_and_nothing_here_can_check_it = true
geo_replication_sku_floor_is_only_partly_checkable = true
has_identity = true
has_no_durability_beyond_the_instance = false
high_availability_enabled = true
hostname = "redis-orders-eastus2.eastus2.redisenterprise.cache.azure.net"
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-cache-prod/providers/Microsoft.Cache/redisEnterprise/redis-orders-eastus2"
identity_principal_id = "b3f9c7a1-5e28-4d04-9a71-6c2e8b4f30d5"
identity_type = "UserAssigned"
is_geo_replication_member = true
location = "eastus2"
name = "redis-orders-eastus2"
name_must_be_regionally_unique = true
persistence_enabled = false
plan_access_is_credential_access = true
public_network_access = "Disabled"
public_network_access_enabled = false
redis_module_count = 1
redis_module_names = ["RedisJSON"]
region_availability_is_not_checked_here = true
resource_group_name = "rg-cache-prod"
sku_name = "Balanced_B5"
tags_are_freely_updatable_on_this_resource = true
this_is_a_different_arm_type_from_redis_cache = true
traffic_is_encrypted_in_transit = true
user_assigned_identity_count = 1
uses_append_only_file_persistence = false
uses_customer_managed_key = true
uses_redis_database_persistence = false

πŸ” Troubleshooting

Symptom Cause Fix
The application cannot reach the cache at all public_network_access defaults to "Disabled" and no private endpoint exists. Create one (example 11), or set "Enabled".
public_network_access must be "Enabled" or "Disabled" A boolean was passed β€” probably copied from this suite's redis-cache module, which uses public_network_access_enabled. Use the string.
sku_name must be one of the 45 published values A Basic/Standard/Premium value from azurerm_redis_cache, or a wrong family letter. The letter differs per family: B, X, A, M.
sku_name is below the floor for geo-replication Balanced_B0 or Balanced_B1 with a group name set. Use Balanced_B3 or larger.
A SKU change produced a destroy-and-create Azure restricts some downgrades and the provider converts them to a replacement. Expected. Scale down in a maintenance window.
All the data disappeared after a routine-looking apply clustering_policy, geo_replication_group_name or module was changed. The plan showed an in-place update. These are create-time decisions. Restore from a persistence backup if one existed.
default_database enables persistence AND sets geo_replication_group_name The two are mutually exclusive per the provider. Choose one durability mechanism.
default_database uses geo-replication with a module that is not permitted RedisBloom or RedisTimeSeries in a geo-replicated group. Only RediSearch and RedisJSON are allowed.
Every default_database.modules entry must have a name of... Usually RedisSearch instead of RediSearch. Lower-case i, capital S.
customer_managed_key names a user-assigned identity that is not attached The key block names an identity that identity does not include. Add the same Resource ID to identity.identity_ids.
customer_managed_key.key_vault_key_id must be a VERSIONED Key Vault key URL key_versionless_ids was passed. Use key_ids["<name>"].
The geo-replication link never forms The members' geo_replication_group_name values differ. Nothing can check this. Derive the name from one local and pass it to every member.
An unexpected key showed up in a state review The access keys are computed, sensitive attributes the provider must read. Expected. Turn access-key auth off and use Entra ID.

πŸ”— Related Docs


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