In this note I will share a few tips & tricks that I am using and which I found that it improved my experience as an OpenTofu user as well as my documentation.
Variables documentation using validation rulesSection titled Variables%20documentation%20using%20validation%20rules
When defining variables in OpenTofu or Terraform, adding descriptions is a community accepted good practice since it helps other team members understand the purpose of the variable faster by having the “documentation” right in the code.
However, for variables that only accept certain values, we can go a step further by implementing validation rules. This provides users with clear, actionable, and immediate feedback when they attempt to use invalid values.
Let’s look at a few fairly typical examples:
variable "environment" { description = "Environment name" type = string
validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be one of: dev, staging, prod." }}
variable "instance_count" { description = "Number of instances to create" type = number
validation { condition = var.instance_count > 0 && var.instance_count <= 10 error_message = "Instance count must be between 1 and 10." }}
variable "allowed_ports" { type = set(number) description = "Set of allowed incoming ports (80, 443, and 139 only)"
validation { condition = alltrue([ for port in var.allowed_ports : contains([80, 443, 139], port) ]) error_message = "Invalid port detected. You may only use port 80 for HTTP, 443 for HTTPS, or 139 for NetBIOS." }}
variable "tags" { description = "Resource tags" type = map(string)
validation { condition = contains(keys(var.tags), "Owner") error_message = "Tags must include an 'Owner' key." }}When using this approach there is a small drawback as the maintainer(s) would have to keep the validations in sync with the system being managed.
To maximize the potential of this approach, a few questions need to be first considered:
- how often does the values change in the managed system?
- are there other variables that are going to depend on the variable with specific validations?
- is this validation used in a module that it is going to impact many other projects when changed?
Variables managementSection titled Variables%20management
Good variables management is required as OpenTofu projects grow or team sizes increases. So, let’s explore a few workflows that would help ease this task for developers.
Splitting into multiple filesSection titled Splitting%20into%20multiple%20files
Rather than keeping all variables definitions in a single variables definitions file, you can split them into logical groups. This approach improves readability, simplifies maintenance, and makes it easier for teams to collaborate as changes can be more easily identified in CVS systems, ie. git.
A common pattern includes separating variables by resource type or functionality.
variable "vpc_cidr" { type = string description = "CIDR block for the VPC" default = "10.0.0.0/16"}
# compute-vars.tfvariable "instance_type" { type = string description = "EC2 instance type" default = "t3.micro"}
# storage-vars.tfvariable "storage_size" { type = number description = "Size of storage in GB" default = 50}And not only variables definitions can be split across files, but also breaking into smaller pieces the files that contain the variable’s values can help make any maintenance task more enjoyable.
This approach is very useful when managing different environments or configurations since any update to an environment is less error prone and easier to validate.
Let’s look at an example deploying to dev and prod environments:
variable "vpc_settings" { type = object({ cidr_block = string name = string subnets = map(string) })}
# dev.tfvarsvpc_settings = { cidr_block = "10.0.0.0/16" name = "dev-vpc" subnets = { public = "10.0.1.0/24" private = "10.0.2.0/24" }}
# prod.tfvarsvpc_settings = { cidr_block = "172.16.0.0/16" name = "prod-vpc" subnets = { public = "172.16.1.0/24" private = "172.16.2.0/24" }}Using the -var-file flag, the appropriate environment variables can be passed to OpenTofu, like this:
$ tofu plan -o plan.out -var-file dev.tfvars # for development env$ tofu plan -o plan.out -var-file prod.tfvars # for production envWhen managing large variables sets, an useful option is to split them based on functionality as well, similar to this:
vpc_cidr = "10.0.0.0/16"subnet_cidrs = { public = "10.0.1.0/24" private = "10.0.2.0/24"}route_tables = { public = "public-rt" private = "private-rt"}
# security.tfvarssecurity_groups = { web = { name = "web-sg" ports = [80, 443] } database = { name = "db-sg" ports = [5432] }}ip_whitelist = ["10.20.0.0/16", "192.168.1.0/24"]
# application.tfvarsapp_settings = { name = "myapp" environment = "production" instance_type = "t3.large"}Using the same -var-file flag multiple times, will merge together all the variables files to form a single set of variables that will be used by OpenTofu at runtime:
$ tofu plan \ -var-file=env/dev/networking.tfvars \ -var-file=env/dev/security.tfvars \ -var-file=env/dev/application.tfvars"The drawback to this approach is that when multiple files define the same variable, the value from the last file will overwrite all others. This applies even if the variable is a map and each file specifies a different set of keys for that map.
For example:
security_groups = { web = { name = "web-sg" ports = [80, 443] }}
# security_db.tfvarssecurity_groups = { database = { name = "db-sg" ports = [5432] }}Issuing the following command:
$ tofu plan \ -var-file=env/dev/security_web.tfvars \ -var-file=env/dev/security_db.tfvarswill result in the following variable being used at runtime:
security_groups = { database = { name = "db-sg" ports = [5432] }}Merging variablesSection titled Merging%20variables
Building on the previous example of splitting variables across files, a solution that can address the potential issue of variable overrides is to
use OpenTofu’s built-in functions merge() and concat() to combine multiple variables into a single variable.
Here’s one example:
web_security_groups = { web = { name = "web-sg" ports = [80, 443] }}
# db-security.tfvarsdb_security_groups = { database = { name = "db-sg" ports = [5432] }}
# main.tflocals { # Merge multiple security group configurations security_groups = merge( { # Default values that can be overridden monitoring = { name = "monitoring-sg" ports = [9090] } }, var.web_security_groups, var.db_security_groups, )}This method prevents accidental overwrites, allows for layered configurations, maintains default values as fallbacks and makes it easier to extend configurations without modifying existing files.
Dependencies between variablesSection titled Dependencies%20between%20variables
Often times systems require specific values to be set based on the values of another configuration option. To achieve this level of flexibility, an OpenTofu’s variable value can be made dependent on another variable’s value. This is possible because OpenTofu processes the variables at runtime before passing them to the code.
Let’s see a few examples:
variable "environment" { type = string default = "dev"}
locals { # Instance type depends on environment instance_type = { dev = "t3.micro" test = "t3.small" prod = "t3.large" }[var.environment]
# Storage configuration based on environment storage_config = { size = var.environment == "prod" ? 1000 : 100 type = var.environment == "prod" ? "io1" : "gp2" }
# Compute capacity based on multiple variables autoscaling_config = { min = var.environment == "prod" ? 3 : 1 max = var.high_availability ? 10 : 3 desired = var.environment == "prod" ? 5 : 1 }}Here, the instance_type variable’s value is actualy a string based on the value of the environment variable which is used as a key to search in a map of possible values.
In the case of the storage_config and autoscaling_config the code uses other variables to set hard values using conditionals.
This is useful when there is a need to very tightly constrain certain parameters of the infrastructure - due to compliance or costs for example.
Using this approach can be applied to variable validations to create a more robust documentation and user experience.
variable "environment_config" { type = object({ name = string # e.g., "dev", "prod" tier = string # e.g., "free", "premium" })
validation { condition = contains(["dev", "prod"], var.environment_config.name) error_message = "Environment name must be either 'dev' or 'prod'" }
validation { condition = contains( var.environment_config.name == "dev" ? ["free"] : ["free", "premium"], var.environment_config.tier ) error_message = "Dev environment can only use 'free' tier. Prod can use 'free' or 'premium'" }}# This is a validenvironment_config = { name = "prod" tier = "premium"}
# Invalid - will fail validationenvironment_config = { name = "dev" tier = "premium" # Error: dev environment can only use 'free' tier}Restructuring dataSection titled Restructuring%20data
As the OpenTofu project evolves the need may arise to “transform” variables into new formats to match new project code or a third-party module’s requirements. This might involve string manipulation, type conversion, or creating completely new variables which combine values from multiple other variables.
Let’s take a look at a few examples:
variable "environment" { type = string default = "dev"}
variable "project" { type = string default = "myapp"}
locals { # Convert to lowercase and add prefix combining multiple vars values resource_prefix = lower("${var.environment}-${var.project}")
# Create standardized name format resource_name = format("%s-%s-%s", local.resource_prefix, var.project, formatdate("YYYY", timestamp()) # resulting in string 2025 )}Closing thoughtsSection titled Closing%20thoughts
By implementing validation rules, you can ensure that variables adhere to expected values, providing immediate feedback and reducing the likelihood of errors. Splitting variables into logical groups and using multiple files can significantly improve readability and maintainability, making it easier for teams to collaborate and manage complex configurations. Additionally, leveraging OpenTofu’s built-in functions like merge() and concat() allows for flexible and dynamic configuration, preventing accidental overwrites and maintaining default values.
Ultimately, these tips and tricks will enhance your development experience, streamline your workflows, and contribute to a more organized and efficient infrastructure management process.
