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.
- βοΈ Creates one
azurerm_managed_redis, namedthisβ the Redis Enterprise cluster and its default database in a single resource. - π
public_network_accessdefaults toDisabled, against the provider'sEnabled. Note it is a string enum, not the boolean this suite'sterraform-azurerm-redis-cachemodule 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_nameandmodule. 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_databaseis grouped because three documented rules span its fields β and the provider enforces only two of them. β οΈ This isMicrosoft.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.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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
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
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. |
| 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_nameandmoduledestroy 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
RediSearchandRedisJSONmay be combined with geo-replication β and the provider declares noConflictsWithfor this one, so nothing but this module checks it. β οΈ public_network_accessis a STRING (Enabled/Disabled), whereterraform-azurerm-redis-cacheuses a booleanpublic_network_access_enabled. Passingfalsehere is a type error, which is the safe direction.β οΈ sku_nameis a closed set of 45 values whose size letter differs per family (B, X, A, M) and does not match the family's initial.β οΈ RediSearchhas a lower-caseiand a capitalSwhere the other three module names are plain Pascal case, so the set cannot be normalised.β οΈ persistence_append_only_file_backup_frequencyhas exactly one legal value,1sβ a single-member enum.- β
tagsare 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.
| 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_accesssays so as an output.
π‘ The way out is to remove the key, not to guard it better.
access_keys_authentication_enabledalready defaults tofalse; grant principals withterraform-azurerm-managed-redis-access-policy-assignmentand there is no shared secret.
Microsoft.Cacheregistered 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 β seea_private_endpoint_is_not_created_here. Balanced_B3or 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.
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.
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.
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 |
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_instanceistruehere. 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 isDisabledanddiverges_from_the_provider_default_public_network_accessreports which way the call went.
π΄ Mind the type. This is a string enum. This suite's
terraform-azurerm-redis-cachemodule takes a booleanpublic_network_access_enabledfor 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_enabledis 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_persistenceanduses_redis_database_persistenceare 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" },
]
}
}
β οΈ RediSearchhas a lower-caseiand a capitalS. 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.RedisSearchis 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_B3or higher is required for geo-replication. The module rejectsBalanced_B0andBalanced_B1alongside 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. Seegeo_replication_sku_floor_is_only_partly_checkable.
π΄ Only
RediSearchandRedisJSONwork with geo-replication, and the provider declares no conflict for that rule β this module is the only thing that catchesRedisBloomorRedisTimeSerieshere.
β οΈ Setting the group name alone replicates nothing. It creates a group whose only member is this database. Theterraform-azurerm-managed-redis-geo-replicationmodule is what links members, and every member must carry the same name β derive it from onelocal, 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
identityincludesUserAssignedand that itsidentity_idscontains 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_enabledtofalse, so the hardened state arrives without this module doing anything. Worth stating because this suite'sterraform-azurerm-redis-cachemodule 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
trueonly 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_nameormodule"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_planis 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 noBasic/Standard/Premiumhere; those belong toazurerm_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_heresays 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. Nodepends_onanywhere.
π΄ The geo-replication module is declared ONCE, not once per member. Linking is reciprocal, and its
idis the anchor'sidβ so a second declaration would collide silently and produce a permanent alternating diff.
β οΈ module.cache_rgis referenced fornameandlocationonly. The resource-group module takestagsas an input and emits none, so tags are declared per module.
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.
| 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.
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, "")).
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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.
| 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.
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
| 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. |
- Provider resource:
azurerm_managed_redis - Platform: Azure Managed Redis persistence
- Platform: Active geo-replication
- Platform: Redis modules
- Platform: Scaling Azure Managed Redis
- Siblings:
terraform-azurerm-managed-redis-geo-replication(links members; declare it once per group),terraform-azurerm-redis-cache(a different ARM type, not a migration target),terraform-azurerm-private-endpoint,terraform-azurerm-key-vault,terraform-azurerm-user-assigned-identity,terraform-azurerm-resource-group,terraform-azurerm-virtual-network - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."