Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Cache for Redis: var-driven SKU sizing, TLS 1.2 enforcement, AAD auth, firewall rules, and a private endpoint, with validated inputs and connection-string outputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "redis_cache" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-redis-cache?ref=v1.0.0"
name = "..." # Globally unique cache name; also the DNS label.
resource_group_name = "..." # Resource group to deploy into.
location = "..." # Azure region (e.g. `centralindia`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Cache for Redis is a fully managed, in-memory key-value store built on the open-source Redis engine. Teams reach for it to offload read traffic from a database, hold session state for stateless web tiers, back distributed locks, rate limiters, and leaderboards, or act as a pub/sub broker between services. Because it lives in memory, single-digit-millisecond reads are the norm — but that same speed makes it a tempting open door if you provision it with the defaults Azure hands you (public access on, non-TLS port reachable, keys-only auth).
The azurerm_redis_cache resource has a deceptively large surface: the family/sku_name/capacity triplet alone determines whether you get a Basic single-node cache, a replicated Standard cache, or a Premium cache with clustering, persistence, zone redundancy, and VNet/private-link support — and several block-level settings are only valid on certain tiers. Hand-writing that for every environment invites drift: a dev cache on minimum_tls_version = "1.0", a prod cache that forgot public_network_access_enabled = false, a staging cache with no firewall rules. Wrapping it in a module pins the secure defaults (TLS 1.2, non-SSL port disabled, public access off when a private endpoint is requested), validates the SKU triplet so an impossible combination fails at plan instead of mid-apply, and emits the connection details downstream resources actually need (hostname, SSL port, primary connection string) — all from a handful of typed variables.
When to use it
- You run more than one Redis instance (per environment, per region, or per app) and want every one of them to land on the same hardened baseline instead of copy-pasted HCL.
- You need a cache that is not reachable from the public internet — fronted by a Private Endpoint into your application VNet — without re-deriving the private-DNS and
public_network_access_enabledwiring each time. - You want AAD/Entra authentication for Redis rather than (or in addition to) shared access keys, and want it toggled consistently.
- You want guardrails: a Premium-only feature (clustering, persistence, zone redundancy) should be rejected at plan time if someone points it at a Basic SKU, not silently ignored or errored half-way through.
- Reach for a different pattern when you need Redis Enterprise modules (RediSearch, RedisJSON, active geo-replication) — those use
azurerm_redis_enterprise_cluster/_database, a separate resource family this module does not cover.
Module structure
terraform-module-azure-redis-cache/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_redis_cache + firewall rules + optional private endpoint
├── variables.tf # typed, validated inputs
└── outputs.tf # id/name/hostname/ports/connection strings
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Premium-only capabilities are gated on the SKU. Used to validate inputs
# and to decide which optional blocks are emitted.
is_premium = var.sku_name == "Premium"
# Public access is forced off whenever a private endpoint is requested,
# regardless of what the caller passed — defence in depth.
public_network_access_enabled = var.private_endpoint == null ? var.public_network_access_enabled : false
}
resource "azurerm_redis_cache" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
capacity = var.capacity
family = var.family
sku_name = var.sku_name
# Hardened transport defaults.
minimum_tls_version = var.minimum_tls_version
non_ssl_port_enabled = var.non_ssl_port_enabled
public_network_access_enabled = local.public_network_access_enabled
# Premium-only: pin the cache into a delegated subnet (VNet injection).
# Mutually exclusive with private_endpoint; use one networking model.
subnet_id = local.is_premium ? var.subnet_id : null
# Premium-only: spread replicas across availability zones.
zones = local.is_premium ? var.zones : null
# Premium-only: number of shards when clustering is enabled (>= 1 enables it).
shard_count = local.is_premium ? var.shard_count : null
# Premium-only: extra read replicas per primary (Standard/Premium pair only).
replicas_per_master = local.is_premium ? var.replicas_per_master : null
replicas_per_primary = local.is_premium ? var.replicas_per_primary : null
redis_configuration {
# AAD/Entra authentication for the data plane.
active_directory_authentication_enabled = var.active_directory_authentication_enabled
# Eviction policy applied when maxmemory is reached.
maxmemory_policy = var.maxmemory_policy
# Premium-only persistence. Both are no-ops on lower tiers, so only set
# them when meaningful to avoid spurious diffs.
rdb_backup_enabled = local.is_premium ? var.rdb_backup_enabled : null
rdb_backup_frequency = local.is_premium && var.rdb_backup_enabled ? var.rdb_backup_frequency : null
rdb_backup_max_snapshot_count = local.is_premium && var.rdb_backup_enabled ? var.rdb_backup_max_snapshot_count : null
rdb_storage_connection_string = local.is_premium && var.rdb_backup_enabled ? var.rdb_storage_connection_string : null
}
# Optional weekly maintenance window so updates land off-peak.
dynamic "patch_schedule" {
for_each = var.patch_schedule
content {
day_of_week = patch_schedule.value.day_of_week
start_hour_utc = patch_schedule.value.start_hour_utc
maintenance_window = patch_schedule.value.maintenance_window
}
}
identity {
type = "SystemAssigned"
}
tags = var.tags
lifecycle {
precondition {
condition = !(var.subnet_id != null && var.private_endpoint != null)
error_message = "Use either subnet_id (VNet injection) or private_endpoint, not both."
}
}
}
# Allow-list source IP ranges. Skipped entirely when a private endpoint is used.
resource "azurerm_redis_firewall_rule" "this" {
for_each = var.private_endpoint == null ? var.firewall_rules : {}
name = each.key
redis_cache_name = azurerm_redis_cache.this.name
resource_group_name = var.resource_group_name
start_ip = each.value.start_ip
end_ip = each.value.end_ip
}
# Optional Private Endpoint into the application VNet.
resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint == null ? 0 : 1
name = "${var.name}-pe"
location = var.location
resource_group_name = var.resource_group_name
subnet_id = var.private_endpoint.subnet_id
private_service_connection {
name = "${var.name}-psc"
private_connection_resource_id = azurerm_redis_cache.this.id
is_manual_connection = false
subresource_names = ["redisCache"]
}
dynamic "private_dns_zone_group" {
for_each = var.private_endpoint.private_dns_zone_ids == null ? [] : [1]
content {
name = "redis-dns-zone-group"
private_dns_zone_ids = var.private_endpoint.private_dns_zone_ids
}
}
tags = var.tags
}
variables.tf
variable "name" {
description = "Globally unique name of the Redis cache (also the DNS label: <name>.redis.cache.windows.net)."
type = string
validation {
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", var.name))
error_message = "name must be 1-63 chars, start with a letter, contain only letters/digits/hyphens, and not end with a hyphen."
}
}
variable "resource_group_name" {
description = "Name of the resource group to deploy the cache into."
type = string
}
variable "location" {
description = "Azure region (e.g. centralindia, eastus)."
type = string
}
variable "sku_name" {
description = "Redis SKU tier: Basic, Standard, or Premium."
type = string
default = "Standard"
validation {
condition = contains(["Basic", "Standard", "Premium"], var.sku_name)
error_message = "sku_name must be one of Basic, Standard, or Premium."
}
}
variable "family" {
description = "SKU family: C for Basic/Standard, P for Premium."
type = string
default = "C"
validation {
condition = contains(["C", "P"], var.family)
error_message = "family must be C (Basic/Standard) or P (Premium)."
}
}
variable "capacity" {
description = "Cache size within the family. C family: 0-6 (C0..C6); P family: 1-5 (P1..P5)."
type = number
default = 1
validation {
condition = var.capacity >= 0 && var.capacity <= 6
error_message = "capacity must be between 0 and 6."
}
}
variable "minimum_tls_version" {
description = "Minimum TLS version clients must use."
type = string
default = "1.2"
validation {
condition = contains(["1.0", "1.1", "1.2"], var.minimum_tls_version)
error_message = "minimum_tls_version must be 1.0, 1.1, or 1.2."
}
}
variable "non_ssl_port_enabled" {
description = "Enable the unencrypted 6379 port. Keep false in production."
type = bool
default = false
}
variable "public_network_access_enabled" {
description = "Allow access from public networks. Forced to false when private_endpoint is set."
type = bool
default = true
}
variable "active_directory_authentication_enabled" {
description = "Enable Microsoft Entra (AAD) authentication for the Redis data plane."
type = bool
default = true
}
variable "maxmemory_policy" {
description = "Eviction policy used when the cache reaches maxmemory."
type = string
default = "volatile-lru"
validation {
condition = contains([
"noeviction", "allkeys-lru", "allkeys-lfu", "allkeys-random",
"volatile-lru", "volatile-lfu", "volatile-random", "volatile-ttl"
], var.maxmemory_policy)
error_message = "maxmemory_policy must be a valid Redis eviction policy."
}
}
# ---- Premium-only knobs ----
variable "subnet_id" {
description = "Premium-only: subnet to inject the cache into (VNet injection). Mutually exclusive with private_endpoint."
type = string
default = null
}
variable "zones" {
description = "Premium-only: availability zones to spread the cache across, e.g. [\"1\", \"2\", \"3\"]."
type = list(string)
default = null
}
variable "shard_count" {
description = "Premium-only: number of shards. A value >= 1 enables clustering."
type = number
default = null
}
variable "replicas_per_master" {
description = "Premium-only: read replicas per primary node (legacy attribute name)."
type = number
default = null
}
variable "replicas_per_primary" {
description = "Premium-only: read replicas per primary node."
type = number
default = null
}
variable "rdb_backup_enabled" {
description = "Premium-only: enable RDB persistence to a storage account."
type = bool
default = false
}
variable "rdb_backup_frequency" {
description = "Premium-only: RDB snapshot frequency in minutes (15, 30, 60, 360, 720, or 1440)."
type = number
default = 60
validation {
condition = contains([15, 30, 60, 360, 720, 1440], var.rdb_backup_frequency)
error_message = "rdb_backup_frequency must be one of 15, 30, 60, 360, 720, 1440."
}
}
variable "rdb_backup_max_snapshot_count" {
description = "Premium-only: number of RDB snapshots to retain."
type = number
default = 1
}
variable "rdb_storage_connection_string" {
description = "Premium-only: connection string of the storage account holding RDB backups."
type = string
default = null
sensitive = true
}
variable "patch_schedule" {
description = "Weekly maintenance windows for Redis server updates."
type = list(object({
day_of_week = string
start_hour_utc = optional(number, 0)
maintenance_window = optional(string, "PT5H")
}))
default = []
}
variable "firewall_rules" {
description = "Map of allow-listed source IP ranges. Ignored when private_endpoint is set."
type = map(object({
start_ip = string
end_ip = string
}))
default = {}
}
variable "private_endpoint" {
description = "Optional Private Endpoint config. When set, public access is disabled and firewall rules are skipped."
type = object({
subnet_id = string
private_dns_zone_ids = optional(list(string))
})
default = null
}
variable "tags" {
description = "Tags applied to all created resources."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Redis cache."
value = azurerm_redis_cache.this.id
}
output "name" {
description = "Name of the Redis cache."
value = azurerm_redis_cache.this.name
}
output "hostname" {
description = "Hostname of the Redis cache (<name>.redis.cache.windows.net)."
value = azurerm_redis_cache.this.hostname
}
output "ssl_port" {
description = "TLS-encrypted port (6380)."
value = azurerm_redis_cache.this.ssl_port
}
output "port" {
description = "Non-TLS port (6379). Only reachable when non_ssl_port_enabled is true."
value = azurerm_redis_cache.this.port
}
output "primary_access_key" {
description = "Primary shared access key for the cache."
value = azurerm_redis_cache.this.primary_access_key
sensitive = true
}
output "primary_connection_string" {
description = "Primary connection string (host:ssl_port plus key)."
value = azurerm_redis_cache.this.primary_connection_string
sensitive = true
}
output "identity_principal_id" {
description = "Principal ID of the system-assigned managed identity."
value = azurerm_redis_cache.this.identity[0].principal_id
}
output "private_endpoint_ip" {
description = "Private IP of the cache when a private endpoint is provisioned, otherwise null."
value = var.private_endpoint == null ? null : azurerm_private_endpoint.this[0].private_service_connection[0].private_ip_address
}
How to use it
module "redis_cache" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-redis-cache?ref=v1.0.0"
name = "kv-prod-sessions"
resource_group_name = azurerm_resource_group.app.name
location = "centralindia"
# Premium P1 with zone redundancy and clustering off.
sku_name = "Premium"
family = "P"
capacity = 1
zones = ["1", "2", "3"]
active_directory_authentication_enabled = true
maxmemory_policy = "allkeys-lru"
# Private-only: no public exposure, served via a Private Endpoint + DNS zone.
private_endpoint = {
subnet_id = azurerm_subnet.private_endpoints.id
private_dns_zone_ids = [azurerm_private_dns_zone.redis.id]
}
patch_schedule = [{
day_of_week = "Sunday"
start_hour_utc = 18
}]
tags = {
environment = "prod"
workload = "web-session-store"
managed_by = "terraform"
}
}
# Downstream: hand the cache hostname + SSL port to the App Service that uses it.
resource "azurerm_linux_web_app" "api" {
name = "kv-prod-api"
resource_group_name = azurerm_resource_group.app.name
location = "centralindia"
service_plan_id = azurerm_service_plan.app.id
site_config {}
app_settings = {
"Redis__Host" = module.redis_cache.hostname
"Redis__Port" = tostring(module.redis_cache.ssl_port)
"Redis__UseSsl" = "true"
"Redis__CacheId" = module.redis_cache.id
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/redis_cache/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-redis-cache?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/redis_cache && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Globally unique cache name; also the DNS label. |
resource_group_name |
string |
— | Yes | Resource group to deploy into. |
location |
string |
— | Yes | Azure region (e.g. centralindia). |
sku_name |
string |
"Standard" |
No | Basic, Standard, or Premium. |
family |
string |
"C" |
No | C for Basic/Standard, P for Premium. |
capacity |
number |
1 |
No | Cache size within the family (C0–C6 / P1–P5). |
minimum_tls_version |
string |
"1.2" |
No | Minimum client TLS version. |
non_ssl_port_enabled |
bool |
false |
No | Enable the unencrypted 6379 port. |
public_network_access_enabled |
bool |
true |
No | Allow public network access; forced false with a private endpoint. |
active_directory_authentication_enabled |
bool |
true |
No | Enable Microsoft Entra (AAD) data-plane auth. |
maxmemory_policy |
string |
"volatile-lru" |
No | Eviction policy at maxmemory. |
subnet_id |
string |
null |
No | Premium-only VNet injection subnet (exclusive with private_endpoint). |
zones |
list(string) |
null |
No | Premium-only availability zones. |
shard_count |
number |
null |
No | Premium-only shard count (≥1 enables clustering). |
replicas_per_master |
number |
null |
No | Premium-only read replicas per primary (legacy name). |
replicas_per_primary |
number |
null |
No | Premium-only read replicas per primary. |
rdb_backup_enabled |
bool |
false |
No | Premium-only RDB persistence toggle. |
rdb_backup_frequency |
number |
60 |
No | Premium-only RDB snapshot frequency (minutes). |
rdb_backup_max_snapshot_count |
number |
1 |
No | Premium-only RDB snapshots retained. |
rdb_storage_connection_string |
string |
null |
No | Premium-only storage account connection string for backups (sensitive). |
patch_schedule |
list(object) |
[] |
No | Weekly maintenance windows. |
firewall_rules |
map(object) |
{} |
No | Allow-listed source IP ranges; ignored with a private endpoint. |
private_endpoint |
object |
null |
No | Private Endpoint config; disables public access when set. |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Redis cache. |
name |
Name of the Redis cache. |
hostname |
Hostname (<name>.redis.cache.windows.net). |
ssl_port |
TLS-encrypted port (6380). |
port |
Non-TLS port (6379); only reachable when non_ssl_port_enabled is true. |
primary_access_key |
Primary shared access key (sensitive). |
primary_connection_string |
Primary connection string (sensitive). |
identity_principal_id |
Principal ID of the system-assigned managed identity. |
private_endpoint_ip |
Private IP when a private endpoint is provisioned, else null. |
Enterprise scenario
A retail platform runs its checkout API across three App Service instances behind a regional load balancer and needs a shared, low-latency session and cart store that never touches the public internet. The team instantiates this module once per region with sku_name = "Premium", zones = ["1", "2", "3"] for AZ resilience, and a private_endpoint wired into the same VNet as the App Services, so the cache is resolvable only via the internal privatelink.redis.cache.windows.net DNS zone. AAD authentication is enabled, the App Services authenticate with their managed identities instead of shared keys, and a Sunday-evening patch_schedule keeps Redis server updates off the Friday/Saturday peak.
Best practices
- Force TLS and kill the plaintext port. Keep
minimum_tls_version = "1.2"andnon_ssl_port_enabled = falseeverywhere — the module defaults to both, so an environment is insecure only if someone explicitly overrides them. - Prefer private networking over IP firewalls. A
private_endpoint(which auto-disables public access) removes the cache from the public surface entirely; reservefirewall_rulesfor the narrow cases where a private endpoint isn’t viable, and never use a0.0.0.0–255.255.255.255allow-all rule. - Authenticate with Entra, not keys. Enable
active_directory_authentication_enabledand let consumers connect via managed identity; treatprimary_access_key/primary_connection_stringoutputs as break-glass secrets, and rotate them through the Azure portal/CLI if they ever leak. - Right-size the SKU for the job. Basic is a single node with no SLA — fine for
dev, never for production. Use Standard (replicated) as the baseline and Premium only when you genuinely need clustering, persistence, zone redundancy, or VNet integration, since Premium is materially more expensive per GB. - Set an eviction policy that matches your workload. Use
allkeys-lrufor a pure cache where any key may be dropped, orvolatile-lru/volatile-ttlwhen only TTL-bearing keys should be evicted — leaving it onnoevictionwill surface out-of-memory write errors under load. - Name and tag for traceability. Encode environment and purpose in
name(e.g.kv-prod-sessions) since it’s globally unique and DNS-visible, and always passenvironment/workload/managed_bytags so cost reports and the maintenance schedule can be attributed per cache.