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?
- Avoid Hard-coding and Embrace DRY: Variables prevent you from hard-coding values directly into your configuration, a practice that leads to duplication and inconsistency. Adhering to the “Don’t Repeat Yourself” (DRY) principle makes your code cleaner and easier to maintain.
- Dynamic and Reusable Configurations: By externalizing configurable aspects, you can reuse the same Terraform code to deploy different instances of infrastructure (e.g., development, testing, and production environments) by simply providing different values for the variables.
- Improved Collaboration: Variables clearly define the configurable aspects of a module, making it easier for team members to understand and use shared infrastructure components.
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:
description: This optional but highly recommended argument provides a human-readable explanation of the variable’s purpose. This description is invaluable for documentation, especially when working in teams, and can be displayed by the Terraform CLI.type: Specifies the data type of the variable, such asstring,number,bool,list,map,object, orany. Defining types helps create more robust and error-resistant code by enforcing expected data structures. Terraform is loosely typed by default at the module level, but explicit type constraints are a sign of high-quality code.default: Provides a fallback value if no other value is explicitly given for the variable. If a default value is set, the variable becomes optional, and Terraform will not interactively prompt the user for input.sensitive = true: Marks the variable as sensitive. When this is set, Terraform attempts to redact the variable’s value from the CLI output. However, it’s crucial to understand that sensitive values are still stored in clear text within the Terraform state file. For true security, external secret management tools should be considered.
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:
.tfvarsfiles: Files namedterraform.tfvarsor*.auto.tfvarsare automatically loaded by Terraform. This is a common method for separating variable definitions from their values, allowing easy switching between configurations (e.g., for different environments).-varand-var-fileoptions: Values can be passed directly via the command line using-var="key=value"or from a specific file using-var-file="path/to/file.tfvars"withterraform planandterraform applycommands.- Environment variables: Terraform looks for environment variables prefixed with
TF_VAR_(e.g.,TF_VAR_resource_group_name). This method is useful for CI/CD pipelines and sensitive data, although secure secret managers are generally preferred for sensitive information.
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?
- Inter-Module Communication: Outputs are essential for linking different Terraform configurations or modules together. For instance, a network module might output subnet IDs that are then consumed as inputs by a server module.
- CI/CD Pipeline Integration: Outputs can feed information to external scripts or Continuous Integration/Continuous Delivery (CI/CD) pipelines, enabling subsequent deployment steps.
- Debugging and Visibility: Outputs provide immediate visibility into the attributes of newly created resources directly in the terminal after
terraform apply, aiding in debugging and understanding the deployed infrastructure. - Remote State Access: When using remote state, outputs from one root module can be accessed by other configurations using the
terraform_remote_statedata source.
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:
value: This is the mandatory argument that specifies the expression whose result will be exposed as the output. This is typically an attribute of a resource created by Terraform.description: Similar to variables, an optional description provides context and aids in documentation.sensitive = true: Marks the output as sensitive. Terraform will redact its value from the CLI output. However, it will still be stored in the Terraform state file in plain text.
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
- From the CLI: Use
terraform output <output_name>to display a specific output’s value, orterraform output -jsonto get all outputs in JSON format, which is highly useful for scripting. - From parent modules: When using modules, you can access outputs from a child module with
module.<module_name>.<output_name>.
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?
- DRY for Expressions: Locals help avoid repeating complex expressions or derived values throughout your code, keeping it clean and maintainable.
- Intermediate Calculations: They are perfect for performing calculations, transformations, or concatenations (e.g., for naming conventions) that are internal to the module.
- Improved Readability: By assigning meaningful names to complex expressions, locals make your code easier to read and understand.
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.
-
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 } -
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}"] } -
Resources (
main.tf): Utilize locals and input variables to provision resources. This often involves meta-arguments likecountorfor_eachfor 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 } -
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:
- Version Control Everything: All your Terraform configuration files, including
variables.tf,outputs.tf, andlocals.tf, should be stored in a version control system like Git. This ensures a complete, auditable history of all infrastructure changes. - Modularize for Reusability: Encapsulate related resources into reusable Terraform modules. Modules should use variables for customization, locals for internal logic, and outputs to expose relevant data to parent modules or other configurations.
- Document Thoroughly: Use the
descriptionargument for all variables and outputs. Maintain clearREADME.mdfiles for your modules, explaining their purpose, inputs, and outputs. Tools liketerraform-docscan automate the generation of documentation from your HCL code. - Secure Sensitive Data: Never hard-code sensitive information (e.g., passwords, API keys) directly in your Terraform files. While
sensitive = truehelps with CLI output, it doesn’t secure the state file. Always use external secret management solutions (e.g., Azure Key Vault, HashiCorp Vault, AWS Secrets Manager) for such data. - Validate Inputs: Implement input validation rules using
validationblocks withinvariabledefinitions to ensure that provided variable values meet specific criteria, enhancing the resilience of your modules. - Automate with CI/CD: Integrate your Terraform workflow into CI/CD pipelines. This ensures that
terraform init,plan, andapplycommands are executed automatically and consistently, reducing human error and accelerating deployments. Displaying a summary of theterraform planis highly recommended in CI/CD pipelines for review. - Test Your Infrastructure: Just like application code, Terraform configurations and modules should be thoroughly tested. This includes unit, integration, and end-to-end tests, often utilizing frameworks like Terratest.
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.
