Mastering Terraform variables, outputs and local values for dynamic infrastructure

Terraform has revolutionized the way organizations manage their cloud infrastructure, transitioning from manual, error-prone configurations (often referred to as “ClickOps”) to automated, code-driven workflows. This shift, known as Infrastructure as Code (IaC), allows infrastructure to be defined, provisioned, and managed using machine-readable definition files, applying software development practices like version control, testing, and automated deployment.

At the heart of creating dynamic, reusable, and maintainable Terraform configurations are three fundamental concepts: variables (input variables), outputs, and local values. These elements enable you to write flexible code that can adapt to different environments and requirements without constant modification, making your infrastructure truly “as code”. This tutorial will guide you through mastering these essential Terraform components.

1. Terraform variables: The inputs to your infrastructure

Imagine building a house: you wouldn’t hard-code every dimension of every room into the blueprint. Instead, you’d specify parameters like “number of bedrooms” or “house style,” allowing for flexibility. In Terraform, input variables serve a similar purpose, acting as parameters for your Terraform modules. They allow users to customize module behavior without altering the source code, much like arguments passed to a function.

Why are they crucial?

Defining input variables Input variables are declared using a variable block. For example, to define a resource group name and a location in Azure:

variable "resource_group_name" {
  description = "The name of the resource group"
  type        = string
  default     = "MyResourceGroup"
}

variable "location" {
  description = "The Azure region where resources will be deployed"
  type        = string
  default     = "westeurope"
}

In this example, resource_group_name and location are the unique names of the variables within the module.

Key arguments for variable blocks:

Using input variables Once defined, you reference variables in your configuration using the var.<variable_name> syntax:

resource "azurerm_resource_group" "rg" {
  name     = var.resource_group_name
  location = var.location
}

Providing values to variables Terraform offers several ways to provide values to variables:

2. Terraform outputs: revealing infrastructure details

After Terraform has successfully provisioned your infrastructure, you often need to retrieve specific information about the created resources. This could be an IP address, a DNS name, a resource ID, or any other attribute. Outputs are Terraform’s mechanism for exposing these crucial details. Think of them as the return values of your Terraform configuration or module.

Why are they crucial?

Declaring output values Outputs are declared using an output block:

output "registry_hostname" {
  description = "The hostname of the container registry"
  value       = azurerm_container_registry.container_registry.login_server
}

Key arguments for output blocks:

Best practice for sensitive outputs: The consensus is to avoid outputting sensitive values in production code. While sensitive = true hides them from the console, the plain-text storage in the state file remains a security risk. For production, retrieve sensitive information directly from secure external secrets managers (e.g., Azure Key Vault) after provisioning.

Accessing outputs

3. Terraform local values: intermediate calculations and DRY principles

Sometimes, you need to define an intermediate value or a complex expression that is used multiple times within a single module but doesn’t need to be exposed as an input or an output. This is where local values (declared using the locals block) come into play. Locals act as temporary, internal variables that simplify and streamline your configuration.

Why are they crucial?

Defining local values Local values are defined within a locals block, which can contain multiple name = expression pairs.

locals {
  app_prefix = "my-app"
  environment = "dev"
  resource_name = "${local.app_prefix}-${local.environment}-rg"
  # Example for dynamic naming based on count (covered later)
  instance_names = [for i in range(3) : "${local.app_prefix}-${local.environment}-instance-${i}"]
}

Using local values You refer to local values using the local.<local_name> syntax:

resource "azurerm_resource_group" "rg" {
  name     = local.resource_name
  location = "westeurope"
}

Scope of local values: It’s important to remember that local values are only visible and accessible within the module where they are defined. They cannot be directly accessed by parent modules or other independent configurations. If a derived value needs to be shared externally, it should be passed as an output from the module where the local is defined.

4. Putting it all together: building dynamic infrastructure

Variables, outputs, and local values combine to unlock the full potential of Terraform for creating truly dynamic infrastructure.

Scenario: provisioning multiple environments dynamically

Consider an application that needs to be deployed across development, testing, and production environments, with slight variations in resource names and counts.

  1. Input Variables (variables.tf): Define variables to control the environment, application name, and number of instances.

    variable "environment" {
      description = "The deployment environment (dev, test, prod)"
      type        = string
    }
    
    variable "app_name_prefix" {
      description = "Prefix for application resources"
      type        = string
      default     = "webapp"
    }
    
    variable "instance_count" {
      description = "Number of app instances to deploy"
      type        = number
      default     = 1
    }
    
  2. Local Values (locals.tf): Use locals to construct dynamic names and derived values based on input variables.

    locals {
      full_app_name = "${var.app_name_prefix}-${var.environment}"
      resource_group_name = "${local.full_app_name}-rg"
      app_instance_names = [for i in range(var.instance_count) : "${local.full_app_name}-${i}"]
    }
    
  3. Resources (main.tf): Utilize locals and input variables to provision resources. This often involves meta-arguments like count or for_each for scaling identical (or similar) resources.

    resource "azurerm_resource_group" "app_rg" {
      name     = local.resource_group_name
      location = "westeurope" # Could also be a variable
    }
    
    resource "azurerm_linux_web_app" "app_instances" {
      count               = var.instance_count
      name                = local.app_instance_names[count.index]
      location            = azurerm_resource_group.app_rg.location
      resource_group_name = azurerm_resource_group.app_rg.name
      service_plan_id     = azurerm_app_service_plan.plan.id
      site_config {}
    }
    
    resource "azurerm_app_service_plan" "plan" {
        name                = "${local.full_app_name}-plan"
        location            = azurerm_resource_group.app_rg.location
        resource_group_name = azurerm_resource_group.app_rg.name
        os_type             = "Linux"
        sku_name            = "B1" # Could also be a variable
    }
    
  4. Outputs (outputs.tf): Expose key information from the provisioned resources.

    output "resource_group_name_deployed" {
      value       = azurerm_resource_group.app_rg.name
      description = "The name of the deployed resource group."
    }
    
    output "app_instance_hostnames" {
      value       = azurerm_linux_web_app.app_instances.*.default_hostname
      description = "Hostnames of the deployed application instances."
    }
    

Now, by simply changing the environment variable in your .tfvars file or via the CLI, you can provision entirely separate, yet consistently configured, sets of infrastructure for different purposes. This iterative “evolutionary architecture” approach allows you to build out your infrastructure little by little, testing at each stage.

5. Best practices for IaC maturity

Mastering variables, outputs, and local values is a significant step towards achieving IaC maturity. To fully leverage their power and maintain robust infrastructure, consider these best practices:

By diligently applying these concepts and best practices, you can move beyond basic infrastructure provisioning and build highly scalable, reliable, and adaptable cloud environments with Terraform, truly mastering Infrastructure as Code.

comments powered by Disqus

Copyright 2025. All rights reserved.