Terraform Interview Questions and Answers Part 4 - Modules and Reuse

Terraform Interview Q&A · Part 4 of 10

In this post we are going to cover Terraform modules and reuse. These are 7 real Terraform interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: the platform team must offer a standard, secure data lake module to ten product teams, and you build and release it.

A module is a folder of .tf files that describes one concept. Variables go in, outputs come out, and everything else is private. On a data platform, modules are how one platform team keeps ten product teams on the same standards. Interviewers ask about modules because a bad module, or a bad module release, breaks many teams at once.

Part 4 of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT.

The short answer

Say this first

A module is a folder of .tf files that describes one concept: variables go in, outputs come out, everything else is private. Pin every module version, ship moved blocks with refactors, keep inputs few with secure defaults, and prove it with terraform test before each release.

Let's set up the project

We use a small Terraform 1.17 project. Inside modules/lake, terraform_data resources stand in for the storage account and its containers, so the plan is real and nothing is created in Azure. The modules/datalake folder is a real azurerm 5.11 module, checked with terraform validate and terraform test. Before the first step we run terraform init in the root folder and in modules/datalake.

main.tf
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  team        = "sales"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
outputs.tf
output "sales_lake_account" {
  value = module.sales_lake.storage_account_name
}

output "finance_containers" {
  value = module.finance_lake.container_names
}
modules/lake/variables.tf
variable "team" {
  type        = string
  description = "Owning team: 2 to 10 lowercase letters."
  validation {
    condition     = can(regex("^[a-z]{2,10}$", var.team))
    error_message = "team must be 2 to 10 lowercase letters."
  }
}

variable "environment" {
  type        = string
  description = "dev, test or prod."
}

variable "containers" {
  type        = set(string)
  description = "Containers to create in the lake."
  default     = ["raw", "curated"]
}
modules/lake/main.tf
locals {
  account_name = "st${var.team}lake${var.environment}"
}

# Stand-ins for azurerm_storage_account and its containers
resource "terraform_data" "account" {
  input = {
    name = local.account_name
    hns  = true
  }
}

resource "terraform_data" "container" {
  for_each = var.containers
  input = {
    name    = each.key
    account = terraform_data.account.output.name
  }
}
modules/lake/outputs.tf
output "storage_account_name" {
  description = "Name of the lake storage account."
  value       = terraform_data.account.output.name
}

output "container_names" {
  description = "Containers in the lake."
  value       = sort(keys(terraform_data.container))
}
modules/community-lake/versions.tf
terraform {
  required_version = ">= 1.3"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.0, < 5.0"
    }
  }
}
modules/datalake/versions.tf
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 5.11, < 6.0"
    }
  }
}
modules/datalake/variables.tf
variable "team" {
  type        = string
  description = "Owning team: 2 to 10 lowercase letters."
  validation {
    condition     = can(regex("^[a-z]{2,10}$", var.team))
    error_message = "team must be 2 to 10 lowercase letters."
  }
}

variable "environment" {
  type        = string
  description = "dev, test or prod."
  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "environment must be one of dev, test, prod."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Existing resource group for the lake."
}

variable "location" {
  type        = string
  description = "Azure region."
}

variable "containers" {
  type        = set(string)
  description = "ADLS Gen2 file systems to create."
  default     = ["raw", "curated"]
}
modules/datalake/main.tf
locals {
  name        = "st${var.team}lake${var.environment}"
  replication = var.environment == "prod" ? "GRS" : "LRS"
}

resource "azurerm_storage_account" "this" {
  name                              = local.name
  resource_group_name               = var.resource_group_name
  location                          = var.location
  account_tier                      = "Standard"
  account_replication_type          = local.replication
  is_hns_enabled                    = true
  shared_access_key_enabled         = false
  public_network_access             = "Disabled"
  allow_nested_items_to_be_public   = false
  min_tls_version                   = "TLS1_2"
  infrastructure_encryption_enabled = true
  blob_properties {
    delete_retention_policy {
      days = 14
    }
  }
}

resource "azurerm_storage_data_lake_gen2_filesystem" "this" {
  for_each           = var.containers
  name               = each.key
  storage_account_id = azurerm_storage_account.this.id
}
modules/datalake/outputs.tf
output "id" {
  description = "Resource ID of the storage account."
  value       = azurerm_storage_account.this.id
}

output "name" {
  description = "Storage account name."
  value       = azurerm_storage_account.this.name
}

output "dfs_endpoint" {
  description = "ADLS Gen2 endpoint for Spark and Databricks."
  value       = azurerm_storage_account.this.primary_dfs_endpoint
}
modules/datalake/tests/secure_defaults.tftest.hcl
mock_provider "azurerm" {}

variables {
  team                = "sales"
  environment         = "prod"
  resource_group_name = "rg-sales-data-prod"
  location            = "eastus2"
}

run "secure_by_default" {
  command = plan

  assert {
    condition = (
      azurerm_storage_account.this.shared_access_key_enabled == false
    )
    error_message = "Shared access keys must be disabled."
  }

  assert {
    condition = (
      azurerm_storage_account.this.public_network_access == "Disabled"
    )
    error_message = "Public network access must be disabled."
  }
}
modules/datalake/tests/inputs.tftest.hcl
mock_provider "azurerm" {}

variables {
  team                = "Sales-Team"
  environment         = "prod"
  resource_group_name = "rg-sales-data-prod"
  location            = "eastus2"
}

run "rejects_bad_team_name" {
  command         = plan
  expect_failures = [var.team]
}

Question 1: Every team copies the same storage account code with small differences. When should you write a module?

Write a module when a group of resources forms one concept that we build again and again, like a team's lake: a storage account and its containers. The copies become one module, and the differences become a few inputs.

  • Copied per team - storage account + containers, slightly different each time
  • One module - modules/lake: team, environment, containers

A module is just a folder of .tf files: main.tf, variables.tf and outputs.tf. modules/lake takes three inputs: team, environment and containers. Sales and finance each call it, and only the differences are arguments: finance adds a restricted container. Local modules need no download: Terraform reads the folder in place. Let's run terraform plan.

Terminal
$ terraform plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.finance_lake.terraform_data.account will be created
  + resource "terraform_data" "account" {
      + id     = (known after apply)
      + input  = {
          + hns  = true
          + name = "stfinancelakedev"
        }
      + output = (known after apply)
    }

  # module.finance_lake.terraform_data.container["curated"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = {
          + account = (known after apply)
          + name    = "curated"
        }
      + output = (known after apply)
    }

  # module.finance_lake.terraform_data.container["raw"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = {
          + account = (known after apply)
          + name    = "raw"
        }
      + output = (known after apply)
    }

  # module.finance_lake.terraform_data.container["restricted"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = {
          + account = (known after apply)
          + name    = "restricted"
        }
      + output = (known after apply)
    }

  # module.sales_lake.terraform_data.account will be created
  + resource "terraform_data" "account" {
      + id     = (known after apply)
      + input  = {
          + hns  = true
          + name = "stsaleslakedev"
        }
      + output = (known after apply)
    }

  # module.sales_lake.terraform_data.container["curated"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = {
          + account = (known after apply)
          + name    = "curated"
        }
      + output = (known after apply)
    }

  # module.sales_lake.terraform_data.container["raw"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = {
          + account = (known after apply)
          + name    = "raw"
        }
      + output = (known after apply)
    }

Plan: 7 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + finance_containers = [
      + "curated",
      + "raw",
      + "restricted",
    ]
  + sales_lake_account = (known after apply)

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan shows every resource under its module address, like module.sales_lake.terraform_data.account. That is Plan: 7 to add, from about fifteen lines of root code.

When not to: the docs advise against thin wrappers around a single resource. If the only name you can find is the resource type, use the resource directly. And keep the module tree flat: one level of child modules.

Rule of thumb

The third copy. By then the differences are clear enough to become inputs.

Question 2: A teammate asks how inputs and outputs pass between a root module and a child module. How do you explain it?

Think of a module as a function. Variables are its parameters, outputs are its return values, and everything inside is private.

The root passes arguments in the module block: team, environment and containers. Inside, they are var.team and so on. The module returns values in its outputs.tf: the storage account name and the container names. The root reads them as module.sales_lake.storage_account_name. Let's apply and run terraform output.

Terminal
$ terraform apply -auto-approve > apply.log
$ terraform output
finance_containers = tolist([
  "curated",
  "raw",
  "restricted",
])
sales_lake_account = "stsaleslakedev"

The root outputs show sales_lake_account = "stsaleslakedev", and the three finance containers.

And the root can only see outputs. Let's try to reach a resource inside the module, module.sales_lake.terraform_data.account.id, and run terraform validate.

outputs.tf
output "sales_lake_account" {
  value = module.sales_lake.storage_account_name
}

output "finance_containers" {
  value = module.finance_lake.container_names
}

# Try to reach inside the module
output "sales_account_id" {
  value = module.sales_lake.terraform_data.account.id
}
Terminal (expected error)
$ terraform validate
Error: Unsupported attribute

  on outputs.tf line 11, in output "sales_account_id":
  11:   value = module.sales_lake.terraform_data.account.id
    ├────────────────
    │ module.sales_lake is object with 2 attributes

This object does not have an attribute named "terraform_data".

Validate fails with Error: Unsupported attribute, and says module.sales_lake is object with 2 attributes. If callers need a value, the module must output it.

Providers

A child module inherits the root's default provider configurations, so a reusable module declares required_providers but no provider blocks. For a second subscription, pass an aliased provider in the module block: providers = { azurerm = azurerm.prod }.

Question 3: A module update broke three teams at once. How do you version and pin modules?

Every caller pins a module version, so a change reaches a team only when that team upgrades. And the module uses semantic versions: breaking changes only in a new major version.

What usually breaks callers is a refactor. Let's act out version 2 of the module: the author renames the resource from account to this. We run terraform plan -refresh=false.

outputs.tf
output "sales_lake_account" {
  value = module.sales_lake.storage_account_name
}

output "finance_containers" {
  value = module.finance_lake.container_names
}
modules/lake/main.tf
locals {
  account_name = "st${var.team}lake${var.environment}"
}

# Stand-ins for azurerm_storage_account and its containers
resource "terraform_data" "this" {
  input = {
    name = local.account_name
    hns  = true
  }
}

resource "terraform_data" "container" {
  for_each = var.containers
  input = {
    name    = each.key
    account = terraform_data.this.output.name
  }
}
modules/lake/outputs.tf
output "storage_account_name" {
  description = "Name of the lake storage account."
  value       = terraform_data.this.output.name
}

output "container_names" {
  description = "Containers in the lake."
  value       = sort(keys(terraform_data.container))
}
Terminal
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy

Terraform will perform the following actions:

  # module.finance_lake.terraform_data.account will be destroyed
  # (because terraform_data.account is not in configuration)
  - resource "terraform_data" "account" {
      - id     = "<uuid>" -> null
      - input  = {
          - hns  = true
          - name = "stfinancelakedev"
        } -> null
      - output = {
          - hns  = true
          - name = "stfinancelakedev"
        } -> null
    }

  # module.finance_lake.terraform_data.container["curated"] will be updated in-place
  ~ resource "terraform_data" "container" {
        id     = "<uuid>"
      ~ input  = {
          ~ account = "stfinancelakedev" -> (known after apply)
            name    = "curated"
        }
      ~ output = {
          - account = "stfinancelakedev"
          - name    = "curated"
        } -> (known after apply)
    }

  # module.finance_lake.terraform_data.container["raw"] will be updated in-place
  ~ resource "terraform_data" "container" {
        id     = "<uuid>"
      ~ input  = {
          ~ account = "stfinancelakedev" -> (known after apply)
            name    = "raw"
        }
      ~ output = {
          - account = "stfinancelakedev"
          - name    = "raw"
        } -> (known after apply)
    }

  # module.finance_lake.terraform_data.container["restricted"] will be updated in-place
  ~ resource "terraform_data" "container" {
        id     = "<uuid>"
      ~ input  = {
          ~ account = "stfinancelakedev" -> (known after apply)
            name    = "restricted"
        }
      ~ output = {
          - account = "stfinancelakedev"
          - name    = "restricted"
        } -> (known after apply)
    }

  # module.finance_lake.terraform_data.this will be created
  + resource "terraform_data" "this" {
      + id     = (known after apply)
      + input  = {
          + hns  = true
          + name = "stfinancelakedev"
        }
      + output = (known after apply)
    }

  # module.sales_lake.terraform_data.account will be destroyed
  # (because terraform_data.account is not in configuration)
  - resource "terraform_data" "account" {
      - id     = "<uuid>" -> null
      - input  = {
          - hns  = true
          - name = "stsaleslakedev"
        } -> null
      - output = {
          - hns  = true
          - name = "stsaleslakedev"
        } -> null
    }

  # module.sales_lake.terraform_data.container["curated"] will be updated in-place
  ~ resource "terraform_data" "container" {
        id     = "<uuid>"
      ~ input  = {
          ~ account = "stsaleslakedev" -> (known after apply)
            name    = "curated"
        }
      ~ output = {
          - account = "stsaleslakedev"
          - name    = "curated"
        } -> (known after apply)
    }

  # module.sales_lake.terraform_data.container["raw"] will be updated in-place
  ~ resource "terraform_data" "container" {
        id     = "<uuid>"
      ~ input  = {
          ~ account = "stsaleslakedev" -> (known after apply)
            name    = "raw"
        }
      ~ output = {
          - account = "stsaleslakedev"
          - name    = "raw"
        } -> (known after apply)
    }

  # module.sales_lake.terraform_data.this will be created
  + resource "terraform_data" "this" {
      + id     = (known after apply)
      + input  = {
          + hns  = true
          + name = "stsaleslakedev"
        }
      + output = (known after apply)
    }

Plan: 2 to add, 5 to change, 2 to destroy.

Changes to Outputs:
  ~ sales_lake_account = "stsaleslakedev" -> (known after apply)

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Every caller's plan now wants to recreate the storage account: terraform_data.account will be destroyed, (because terraform_data.account is not in configuration), and terraform_data.this will be created. The summary is Plan: 2 to add, 5 to change, 2 to destroy.

The fix ships inside the module: a moved block, from the old address to the new one. Let's add it and apply.

modules/lake/main.tf
locals {
  account_name = "st${var.team}lake${var.environment}"
}

# Stand-ins for azurerm_storage_account and its containers
resource "terraform_data" "this" {
  input = {
    name = local.account_name
    hns  = true
  }
}

resource "terraform_data" "container" {
  for_each = var.containers
  input = {
    name    = each.key
    account = terraform_data.this.output.name
  }
}

# Released with the rename: callers keep their objects
moved {
  from = terraform_data.account
  to   = terraform_data.this
}
Terminal
$ terraform apply -auto-approve -refresh=false
Terraform will perform the following actions:

  # module.finance_lake.terraform_data.account has moved to module.finance_lake.terraform_data.this
    resource "terraform_data" "this" {
        id     = "<uuid>"
        # (2 unchanged attributes hidden)
    }

  # module.sales_lake.terraform_data.account has moved to module.sales_lake.terraform_data.this
    resource "terraform_data" "this" {
        id     = "<uuid>"
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

finance_containers = tolist([
  "curated",
  "raw",
  "restricted",
])
sales_lake_account = "stsaleslakedev"

Now it says has moved to, and Apply complete! Resources: 0 added, 0 changed, 0 destroyed. Apply just records the move for every caller.

How we pin depends on the source. A registry module takes a version constraint. A Git source pins with ref, a tag or a commit.

sources.tf
# Registry (public or private): a version constraint
module "lake" {
  source  = "app.terraform.io/contoso/lake/azurerm"
  version = "~> 1.4.0"
}

# Git: pin a tag or a commit with ref
module "lake_git" {
  source = "git::https://github.com/contoso/tf-lake.git?ref=v1.4.2"
}

The version argument only works for registry modules. Let's add version = "1.2.0" to our local module and run terraform init.

main.tf
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  version     = "1.2.0"
  team        = "sales"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
Terminal (expected error)
$ terraform init
Initializing the backend...

Initializing modules...

Error: Invalid registry module source address

  on main.tf line 6, in module "sales_lake":
   6:   source      = "./modules/lake"

Failed to parse module registry address: can't use local directory
"./modules/lake" as a module registry address.

Terraform assumed that you intended a module registry source address because
you also set the argument "version", which applies only to registry modules.

Init fails: Error: Invalid registry module source address, because version applies only to registry modules.

Pitfall

The lock file records providers, not modules, so the pin in the module block is your only protection. Pin an exact version or a tight range like ~> 1.4.0, and let each team upgrade with its own reviewed plan.

Question 4: Your manager asks whether to use public registry modules or write your own. How do you decide?

It's a trade-off between speed and fit.

Public registry moduleYour own module
Startfast: versioned and documentedslower: you write and test it
Fitgeneric, often many inputsexactly your standards
Controlthe author's release scheduleyour releases, your reviews
Riskreview source, license, provider pinsyou own the maintenance

A public module is fast to start, versioned and documented. But it is often generic, with many inputs, and we depend on someone else's release schedule. Our own module costs time to maintain, but it encodes our standards: naming, network rules, identity and tags.

Before we adopt one, we read its source, license and provider constraints. Let's try it. A stand-in community module still requires azurerm below 5 (>= 3.0, < 5.0), and our root needs ~> 5.11. We run terraform init.

main.tf
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 5.11" }
  }
}

# Stand-in for a public registry module we want to evaluate
module "community_lake" {
  source = "./modules/community-lake"
}
Terminal (expected error)
$ terraform init
Initializing the backend...

Initializing modules...
- community_lake in modules/community-lake

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding hashicorp/azurerm versions matching ">= 3.0.0, < 5.0.0, ~> 5.11"...

Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider
hashicorp/azurerm: no available releases match the given constraints >=
3.0.0, < 5.0.0, ~> 5.11

To see which modules are currently depending on hashicorp/azurerm and what
versions are specified, run the following command:
    terraform providers

Init fails: no available releases match the given constraints. Then terraform providers shows who asks for what.

Terminal
$ terraform providers
Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/azurerm] ~> 5.11
└── module.community_lake
    └── provider[registry.terraform.io/hashicorp/azurerm] >= 3.0.0, < 5.0.0

Providers required by state:

    provider[terraform.io/builtin/terraform]

The root asks for ~> 5.11, and module.community_lake asks for >= 3.0.0, < 5.0.0.

The docs recommend local paths for closely related modules, and a module registry for modules shared by many configurations. For a platform team, that usually means a private registry, like the one in HCP Terraform.

My rule:

  • Registry modules - generic building blocks; on Azure, Azure Verified Modules first
  • Own modules - where our security standards live
  • Every module - pinned and reviewed like our own code

Question 5: A module has thirty variables and nobody understands it. What makes a good module design?

A good module describes one concept, with a few required inputs, safe defaults for the rest, and clear outputs. Thirty variables usually means it's trying to be every module at once.

Our lake module has two required inputs, team and environment, and one optional input with a default, containers. Each has a type and a description, and team is validated.

Validation inside the module protects every caller. Let's pass team = "Sales Team", with a capital and a space, and run terraform plan.

main.tf
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  team        = "Sales Team"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
Terminal (expected error)
$ terraform plan
Error: Invalid value for variable

  on main.tf line 7, in module "sales_lake":
   7:   team        = "Sales Team"
    ├────────────────
    │ var.team is "Sales Team"

team must be 2 to 10 lowercase letters.

This was checked by the validation rule at modules/lake/variables.tf:4,3-13.

The plan fails at the caller's module block, on main.tf line 7, in module "sales_lake", with the module's own message: team must be 2 to 10 lowercase letters.

Then the rest of a good design:

  • required_providers, no provider blocks
  • Secure defaults, not every flag
  • One typed object for related settings (with optional attributes)
  • README, an example, tests
Tip

When one team asks for yet another variable, first ask whether it should compose its own resource next to the module.

Question 6: A team needs to change a resource inside a module without forking it. What are the options?

From best to worst:

  • Use an existing input - finance passes its own containers
  • Compose next to the module - your resource + the module outputs
  • Add an input upstream - pull request + minor release, for every team
  • Override files - *_override.tf, in the same folder: special circumstances only
  • Fork - last resort: you own every upgrade

Let's try composition. Sales needs a sandbox container. The module doesn't offer one, so the root adds it in sandbox.tf, using module.sales_lake.storage_account_name. We run terraform plan -refresh=false.

main.tf
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  team        = "sales"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
sandbox.tf
# Composition: our own resource next to the module, using its output
resource "terraform_data" "sales_sandbox" {
  input = {
    name    = "sandbox"
    account = module.sales_lake.storage_account_name
  }
}
Terminal
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # terraform_data.sales_sandbox will be created
  + resource "terraform_data" "sales_sandbox" {
      + id     = (known after apply)
      + input  = {
          + account = "stsaleslakedev"
          + name    = "sandbox"
        }
      + output = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan adds one resource, terraform_data.sales_sandbox, with account = "stsaleslakedev", and the module stays untouched.

Some changes can only happen in the module: lifecycle settings like ignore_changes or prevent_destroy accept only literal values, not variables. And override files only merge into objects in their own folder, so for a shared module that means editing it anyway.

In practice

Compose first, contribute upstream second. Forking means you now own every future upgrade.

Scenario 7: The platform team must offer a standard, secure data lake module to ten product teams. How do you build and release it?

Bake security into the defaults, keep the inputs few, prove it with tests, and release it like a product: versions, a changelog and a registry.

modules/datalake takes team, environment, resource group, location and containers. Everything else is decided once:

  • ADLS Gen2: is_hns_enabled
  • No shared keys: callers set storage_use_azuread
  • Public access Disabled: the runner needs a private endpoint
  • TLS1_2: the only value in azurerm 5.x
  • Infrastructure encryption, GRS in prod, soft delete 14 days

Let's run terraform -chdir=modules/datalake validate, then terraform -chdir=modules/datalake test. The test uses a mocked azurerm provider, so it needs no Azure credentials. One file asserts the secure defaults, and one expects a bad team name to fail.

Terminal
$ terraform -chdir=modules/datalake validate
Success! The configuration is valid.
$ terraform -chdir=modules/datalake test
tests/inputs.tftest.hcl... in progress
  run "rejects_bad_team_name"... pass
tests/inputs.tftest.hcl... tearing down
tests/inputs.tftest.hcl... pass
tests/secure_defaults.tftest.hcl... in progress
  run "secure_by_default"... pass
tests/secure_defaults.tftest.hcl... tearing down
tests/secure_defaults.tftest.hcl... pass

Success! 2 passed, 0 failed.

Validate passes, and the test says Success! 2 passed, 0 failed. Now someone changes public_network_access to "Enabled". We run the test again.

modules/datalake/main.tf
locals {
  name        = "st${var.team}lake${var.environment}"
  replication = var.environment == "prod" ? "GRS" : "LRS"
}

resource "azurerm_storage_account" "this" {
  name                              = local.name
  resource_group_name               = var.resource_group_name
  location                          = var.location
  account_tier                      = "Standard"
  account_replication_type          = local.replication
  is_hns_enabled                    = true
  shared_access_key_enabled         = false
  public_network_access             = "Enabled"
  allow_nested_items_to_be_public   = false
  min_tls_version                   = "TLS1_2"
  infrastructure_encryption_enabled = true
  blob_properties {
    delete_retention_policy {
      days = 14
    }
  }
}

resource "azurerm_storage_data_lake_gen2_filesystem" "this" {
  for_each           = var.containers
  name               = each.key
  storage_account_id = azurerm_storage_account.this.id
}
Terminal (expected error)
$ terraform -chdir=modules/datalake test
tests/inputs.tftest.hcl... in progress
  run "rejects_bad_team_name"... pass
tests/inputs.tftest.hcl... tearing down
tests/inputs.tftest.hcl... pass
tests/secure_defaults.tftest.hcl... in progress
  run "secure_by_default"... fail

Error: Test assertion failed

  on tests/secure_defaults.tftest.hcl line 21, in run "secure_by_default":
  21:     condition = (
  22:       azurerm_storage_account.this.public_network_access == "Disabled"
  23:     )
    ├────────────────
    │ azurerm_storage_account.this.public_network_access is "Enabled"

Public network access must be disabled.
tests/secure_defaults.tftest.hcl... tearing down
tests/secure_defaults.tftest.hcl... fail

Failure! 1 passed, 1 failed.

The test fails with the real value, public_network_access is "Enabled", and our message, Public network access must be disabled., before any release.

Then we release it:

  • Tag a semantic version - v1.4.0, with a CHANGELOG entry
  • Publish to the private registry - README, example, inputs and outputs
  • Teams pin a version - a pilot team upgrades first
  • Breaking change = new major version - moved blocks + an upgrade guide

Each product team then calls it from the registry with a pinned version.

consumer.tf
module "sales_lake" {
  source  = "app.terraform.io/contoso/datalake/azurerm"
  version = "~> 1.4.0"
  # team, environment, resource_group_name, location
}
Guardrail

Azure Policy still enforces. A module sets defaults, it can't enforce them.

Key points to remember

  • A module = one concept, not a thin wrapper around one resource
  • Variables in, outputs out; the root sees only outputs
  • Pin every module version; ship moved blocks with refactors
  • Registry modules: review source, license, provider pins
  • Secure defaults, few inputs, terraform test before each release

The full script

This is a Python script that writes every file and runs every command above in order: put Terraform 1.17 on your PATH and run it in an empty folder.

Show the full script that writes the files and runs every step
Full script - writes the Terraform files and runs every step (Terraform 1.17 on your PATH), run it with Python
# ========================================================================
# Terraform Interview Q&A #4 - Modules and reuse
# TechBrothersIT. Names and sample values are invented for teaching.
# Every step below was run with Terraform 1.17.0; the outputs in the video and the blog post are the real outputs.
# Providers used: hashicorp/random, null, local, time (they create nothing in any cloud). Azure and Databricks
# code in this series is checked with terraform validate only: nothing is created in a real subscription.
# To run it yourself (Linux, macOS or WSL; some steps use grep / cat):
#   1. Install Terraform 1.17 from developer.hashicorp.com/terraform/install and put it on your PATH.
#   2. python Terraform_Interview_QA_04_Code.py   in an empty folder (terraform init downloads the providers).
# ========================================================================

import os, subprocess

def write(path, text):
    if text is None:
        if os.path.exists(path): os.remove(path)
        return
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text.strip("\n") + "\n")

def run(cmd, expect_error=False, allow_error=False, env=None):
    print("\n$ " + cmd, flush=True)
    rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
    if rc != 0 and not (expect_error or allow_error):
        raise SystemExit("stopped: this command was expected to succeed")

# Step 0: the starting files
write('main.tf', """\
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  team        = "sales"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
""")
write('outputs.tf', """\
output "sales_lake_account" {
  value = module.sales_lake.storage_account_name
}

output "finance_containers" {
  value = module.finance_lake.container_names
}
""")
write('modules/lake/variables.tf', """\
variable "team" {
  type        = string
  description = "Owning team: 2 to 10 lowercase letters."
  validation {
    condition     = can(regex("^[a-z]{2,10}$", var.team))
    error_message = "team must be 2 to 10 lowercase letters."
  }
}

variable "environment" {
  type        = string
  description = "dev, test or prod."
}

variable "containers" {
  type        = set(string)
  description = "Containers to create in the lake."
  default     = ["raw", "curated"]
}
""")
write('modules/lake/main.tf', """\
locals {
  account_name = "st${var.team}lake${var.environment}"
}

# Stand-ins for azurerm_storage_account and its containers
resource "terraform_data" "account" {
  input = {
    name = local.account_name
    hns  = true
  }
}

resource "terraform_data" "container" {
  for_each = var.containers
  input = {
    name    = each.key
    account = terraform_data.account.output.name
  }
}
""")
write('modules/lake/outputs.tf', """\
output "storage_account_name" {
  description = "Name of the lake storage account."
  value       = terraform_data.account.output.name
}

output "container_names" {
  description = "Containers in the lake."
  value       = sort(keys(terraform_data.container))
}
""")
write('modules/community-lake/versions.tf', """\
terraform {
  required_version = ">= 1.3"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.0, < 5.0"
    }
  }
}
""")
write('modules/datalake/versions.tf', """\
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 5.11, < 6.0"
    }
  }
}
""")
write('modules/datalake/variables.tf', """\
variable "team" {
  type        = string
  description = "Owning team: 2 to 10 lowercase letters."
  validation {
    condition     = can(regex("^[a-z]{2,10}$", var.team))
    error_message = "team must be 2 to 10 lowercase letters."
  }
}

variable "environment" {
  type        = string
  description = "dev, test or prod."
  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "environment must be one of dev, test, prod."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Existing resource group for the lake."
}

variable "location" {
  type        = string
  description = "Azure region."
}

variable "containers" {
  type        = set(string)
  description = "ADLS Gen2 file systems to create."
  default     = ["raw", "curated"]
}
""")
write('modules/datalake/main.tf', """\
locals {
  name        = "st${var.team}lake${var.environment}"
  replication = var.environment == "prod" ? "GRS" : "LRS"
}

resource "azurerm_storage_account" "this" {
  name                              = local.name
  resource_group_name               = var.resource_group_name
  location                          = var.location
  account_tier                      = "Standard"
  account_replication_type          = local.replication
  is_hns_enabled                    = true
  shared_access_key_enabled         = false
  public_network_access             = "Disabled"
  allow_nested_items_to_be_public   = false
  min_tls_version                   = "TLS1_2"
  infrastructure_encryption_enabled = true
  blob_properties {
    delete_retention_policy {
      days = 14
    }
  }
}

resource "azurerm_storage_data_lake_gen2_filesystem" "this" {
  for_each           = var.containers
  name               = each.key
  storage_account_id = azurerm_storage_account.this.id
}
""")
write('modules/datalake/outputs.tf', """\
output "id" {
  description = "Resource ID of the storage account."
  value       = azurerm_storage_account.this.id
}

output "name" {
  description = "Storage account name."
  value       = azurerm_storage_account.this.name
}

output "dfs_endpoint" {
  description = "ADLS Gen2 endpoint for Spark and Databricks."
  value       = azurerm_storage_account.this.primary_dfs_endpoint
}
""")
write('modules/datalake/tests/secure_defaults.tftest.hcl', """\
mock_provider "azurerm" {}

variables {
  team                = "sales"
  environment         = "prod"
  resource_group_name = "rg-sales-data-prod"
  location            = "eastus2"
}

run "secure_by_default" {
  command = plan

  assert {
    condition = (
      azurerm_storage_account.this.shared_access_key_enabled == false
    )
    error_message = "Shared access keys must be disabled."
  }

  assert {
    condition = (
      azurerm_storage_account.this.public_network_access == "Disabled"
    )
    error_message = "Public network access must be disabled."
  }
}
""")
write('modules/datalake/tests/inputs.tftest.hcl', """\
mock_provider "azurerm" {}

variables {
  team                = "Sales-Team"
  environment         = "prod"
  resource_group_name = "rg-sales-data-prod"
  location            = "eastus2"
}

run "rejects_bad_team_name" {
  command         = plan
  expect_failures = [var.team]
}
""")
run('terraform init')
run('terraform -chdir=modules/datalake init')
run('terraform fmt -check -recursive')

# Step 1: Two teams call one local module (init ran in setup)
run('terraform plan')

# Step 2: Inputs in, outputs out
run('terraform apply -auto-approve > apply.log')
run('terraform output')

# Step 3: The root cannot reach inside a module
write('outputs.tf', """\
output "sales_lake_account" {
  value = module.sales_lake.storage_account_name
}

output "finance_containers" {
  value = module.finance_lake.container_names
}

# Try to reach inside the module
output "sales_account_id" {
  value = module.sales_lake.terraform_data.account.id
}
""")
run('terraform validate', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 4: Module v2 renames a resource, without a moved block
write('outputs.tf', """\
output "sales_lake_account" {
  value = module.sales_lake.storage_account_name
}

output "finance_containers" {
  value = module.finance_lake.container_names
}
""")
write('modules/lake/main.tf', """\
locals {
  account_name = "st${var.team}lake${var.environment}"
}

# Stand-ins for azurerm_storage_account and its containers
resource "terraform_data" "this" {
  input = {
    name = local.account_name
    hns  = true
  }
}

resource "terraform_data" "container" {
  for_each = var.containers
  input = {
    name    = each.key
    account = terraform_data.this.output.name
  }
}
""")
write('modules/lake/outputs.tf', """\
output "storage_account_name" {
  description = "Name of the lake storage account."
  value       = terraform_data.this.output.name
}

output "container_names" {
  description = "Containers in the lake."
  value       = sort(keys(terraform_data.container))
}
""")
run('terraform plan -refresh=false')

# Step 5: The same release with a moved block
write('modules/lake/main.tf', """\
locals {
  account_name = "st${var.team}lake${var.environment}"
}

# Stand-ins for azurerm_storage_account and its containers
resource "terraform_data" "this" {
  input = {
    name = local.account_name
    hns  = true
  }
}

resource "terraform_data" "container" {
  for_each = var.containers
  input = {
    name    = each.key
    account = terraform_data.this.output.name
  }
}

# Released with the rename: callers keep their objects
moved {
  from = terraform_data.account
  to   = terraform_data.this
}
""")
run('terraform apply -auto-approve -refresh=false')

# Step 6: version works only for registry modules
write('main.tf', """\
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  version     = "1.2.0"
  team        = "sales"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
""")
run('terraform init', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 7: A community module that still requires azurerm 4.x
write('main.tf', """\
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 5.11" }
  }
}

# Stand-in for a public registry module we want to evaluate
module "community_lake" {
  source = "./modules/community-lake"
}
""")
run('terraform init', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 8: terraform providers shows which module asks for which version
run('terraform providers')

# Step 9: Validation inside the module protects every caller
write('main.tf', """\
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  team        = "Sales Team"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
""")
run('terraform plan', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 10: Composition: add a resource next to the module
write('main.tf', """\
terraform {
  required_version = ">= 1.17"
}

module "sales_lake" {
  source      = "./modules/lake"
  team        = "sales"
  environment = "dev"
}

module "finance_lake" {
  source      = "./modules/lake"
  team        = "finance"
  environment = "dev"
  containers  = ["raw", "curated", "restricted"]
}
""")
write('sandbox.tf', """\
# Composition: our own resource next to the module, using its output
resource "terraform_data" "sales_sandbox" {
  input = {
    name    = "sandbox"
    account = module.sales_lake.storage_account_name
  }
}
""")
run('terraform plan -refresh=false')

# Step 11: The secure data lake module: validate and test with a mocked azurerm provider
run('terraform -chdir=modules/datalake validate')
run('terraform -chdir=modules/datalake test')

# Step 12: Someone weakens a default: the test fails before release
write('modules/datalake/main.tf', """\
locals {
  name        = "st${var.team}lake${var.environment}"
  replication = var.environment == "prod" ? "GRS" : "LRS"
}

resource "azurerm_storage_account" "this" {
  name                              = local.name
  resource_group_name               = var.resource_group_name
  location                          = var.location
  account_tier                      = "Standard"
  account_replication_type          = local.replication
  is_hns_enabled                    = true
  shared_access_key_enabled         = false
  public_network_access             = "Enabled"
  allow_nested_items_to_be_public   = false
  min_tls_version                   = "TLS1_2"
  infrastructure_encryption_enabled = true
  blob_properties {
    delete_retention_policy {
      days = 14
    }
  }
}

resource "azurerm_storage_data_lake_gen2_filesystem" "this" {
  for_each           = var.containers
  name               = each.key
  storage_account_id = azurerm_storage_account.this.id
}
""")
run('terraform -chdir=modules/datalake test', expect_error=True)   # EXPECTED TO FAIL: the video shows this error
Next in this series

Part 5: Count, for_each and Dynamic Blocks

The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run with Terraform 1.17.0; IDs, timestamps and durations are masked. Azure and Databricks code is checked with terraform validate. Part of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official Terraform documentation (developer.hashicorp.com/terraform) for anything you quote in an interview.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.