The Redis Module: The Floor of a Good Module
The smallest template in the catalog - a hundred lines of Redis cache config - as a study in what even the simplest secure-PaaS wrapper must still carry, and the one pipeline typo that quietly broke environment isolation. Part 8 of the Terraform Module Catalog.
After two articles about the catalog's heavyweights, this one is about its featherweight. The Redis template is the smallest stack in the library — a locals file you can read in one screenful, a module call of barely twenty lines, no data sources at all. Which makes it the perfect specimen for a question every platform team eventually argues about: what is the floor? When you strip a secure-PaaS wrapper down to the absolute minimum, what must still be in the box?
This is Part 8 of the Terraform Module Catalog, companion to the main Terraform on Azure series; the chassis these stacks share is covered there and in Part 2, so here we go straight to the cache.
Everything it carries, and why each item survived the cut
The service-specific surface is four values:
redis_cache_name_list = {
dev = "redis-orders-dev"
staging = "redis-orders-staging"
prod = "redis-orders-prod"
}
family_list = { dev = "C", staging = "C", prod = "P" }
sku_name_list = { dev = "Standard", staging = "Standard", prod = "Premium" }
capacity_list = { dev = 1, staging = 1, prod = 1 }
redis_cache_name = lookup(local.redis_cache_name_list, local.env)
sku_name = lookup(local.sku_name_list, local.env)
family = lookup(local.family_list, local.env)
capacity = lookup(local.capacity_list, local.env)
Azure's Redis sizing is a triple — family, SKU, capacity — and the triple is load-bearing: P/Premium is not just bigger, it is the tier where VNet-adjacent deployment, persistence and zone options live. A C-family cache cannot be made private-only in the way the platform's posture assumes, so the family switch between staging and prod is really a security-posture switch wearing a sizing costume. That's worth knowing when you review the one-line diff.
Everything else in the file is platform plumbing — and this is the answer to the floor question, because none of it got cut even in the smallest module:
- CMK encryption. Key Vault name and key name resolved from platform JSON; data at rest is encrypted under a customer-managed key even for a cache.
- Private link. VNet, VNet resource group and the delegated private-endpoint subnet, all platform facts. The cache has no public face.
- The shared-subscription provider. The
azurerm.sharedalias rides along so the module can reach hub-side private DNS when it wires the endpoint. - Governance tags. Four of them, with one small excellence I want to spotlight:
tags = {
AppID = "APM0012345"
BU = "platform"
BUSub = "commerce"
Lifecycle = terraform.workspace
}
Most stacks in this library write Lifecycle per environment by hand, and at least one deployed stack I've seen carried a typo through every environment for years. This template computes it from the workspace. A tag that is derived cannot drift, cannot be typo'd per environment, and cannot lie about which environment a resource belongs to — which matters, because Lifecycle is exactly the tag cost reports and cleanup scripts filter on. It is a one-line idea I now copy into every estate I touch.
So the floor, stated plainly: identity (name/region/RG), the sizing knobs that double as security tiers, encryption under your own key, a private network posture, cross-subscription reach, and tags that cannot drift. A wrapper smaller than this isn't minimal; it's incomplete.
Strict on purpose
Notice what the lookups above don't have: a default. The platform facts use the forgiving three-argument lookup(..., null) as usual, but every cache-defining value uses the two-argument form, which fails the plan immediately if the environment key is missing. Run a qa workspace against this stack and you get an error naming the exact map — not a null slithering three modules deep before detonating. In the smallest module the choice is easiest to see: platform facts are someone else's schema, so tolerate; your own required values, so fail loudly. The library's best templates apply that rule; this one applies it most consistently.
Also visible at this size: what the wrapper deliberately leaves out. No redis_config block, no maxmemory policy, no firewall rules, no clustering or replica options. That is defensible — the floor shouldn't speculate — but it has a consequence: the day a team needs a maxmemory-policy of allkeys-lru, their options are fork the wrapper or bump the module. A minimal surface is a bet that the module repo's release cadence can keep up with the first real workload. Price the bet before you place it.
And one omission sits below any reasonable floor: there is no diagnostics wiring at all. Every sibling template resolves the platform's diagnostics storage account with a data source and passes its ID into the module; this one has no data.tf and passes nothing. A cache is precisely the resource whose bad day (evictions, connection storms, memory pressure) you want telemetry for, and the smallest template is the one most likely to be copied by the team least likely to notice what's missing. Minimalism is a virtue in inputs; it is a defect in observability.
The defect: one hardcoded dev and state isolation is gone
Now the honest ledger, from an estate I worked on, and this one is the sharpest defect in the whole catalog relative to its size. The pipeline's init step wires the state backend from spoke-selected variables — except in this template, someone had hardcoded the environment:
backendAzureRmResourceGroupName: '$(dev_spoke_devops_storage_account_resource_group)'
backendAzureRmStorageAccountName: '$(dev_spoke_devops_storage_account_name)'
Every sibling template interpolates the selection — $(${{ parameters.spokeSelection }}_spoke_devops_storage_account_name). Here, dev was pasted where the parameter belonged. The consequence is quiet and nasty: run this pipeline against staging or prod and everything works — plan, apply, green ticks — but the staging and prod state files land in the dev spoke's storage account. The estate's entire state-isolation design (main series, Part 3) — separate storage, separate subscriptions, separate access boundaries per environment — silently undone for this one service, by three missing characters of templating.
What makes it teaching material is that nothing ever fails. Terraform doesn't care where its blob lives; the resources still deploy to the right subscription. You discover it during an audit, or when dev's storage account is cleaned up and prod's state vanishes with it. Copy-paste defects in pipelines are worse than copy-paste defects in HCL, because HCL at least gets a plan diff — pipeline YAML gets reviewed once, at template-copy time, by someone whose eyes are on the Terraform. A tiny lint rule (“no literal environment names in backend config”) would have caught it in every one of the hundreds of stacks copied from these templates.
Two smaller nits complete the ledger: capacity was quoted — "2", a string where a number belongs, which azurerm happens to coerce today and which type-checking would reject the day the module declares number; and the provider block pinned its version inline (version = "~> 2.x" inside provider), a pre-0.13 idiom that modern Terraform warns about and that belongs in required_providers. Small module, full-size lessons.
Next in the catalog, a module that accidentally invented a feature: a search template that cannot be applied until you've made a decision — Part 9, Cognitive Search.