Simple string and number variables get you started with Terraform—but they fall apart quickly in real-world setups. As soon as you’re managing multiple environments, services, or configurations, you need something more expressive.
That’s where complex variables in Terraform come in. They allow you to model structured data—like configurations, resource definitions, and environment-specific settings—cleanly and predictably.
Why basic variables aren’t enough
Imagine you're provisioning multiple services with different configurations:
- Each service has a name
- A port
- Optional environment variables
- Scaling configuration
Trying to model this with flat variables quickly becomes messy:
service_name_1, service_port_1, service_name_2...
This is brittle and hard to maintain. Complex variables solve this by grouping related data into structured types.
Object variables: the building block
The most commonly used complex type is the object.
Here’s a simple example:
1variable "app_config" {
2 type = object({
3 name = string
4 port = number
5 debug = bool
6 })
7}Usage:
1resource "docker_container" "app" {
2 name = var.app_config.name
3 ports {
4 internal = var.app_config.port
5 }
6}This keeps related values grouped logically and avoids scattered variables.
Default values for objects
You can define defaults as well:
1variable "app_config" {
2 type = object({
3 name = string
4 port = number
5 debug = bool
6 })
7
8 default = {
9 name = "my-app"
10 port = 8080
11 debug = false
12 }
13}Maps: dynamic configurations
Maps are ideal when you don’t know the keys ahead of time.
Example: tagging resources dynamically.
1variable "tags" {
2 type = map(string)
3 default = {
4 environment = "dev"
5 owner = "team-a"
6 }
7}Usage:
1resource "aws_instance" "example" {
2 ami = "ami-123"
3 instance_type = "t2.micro"
4
5 tags = var.tags
6}This makes your configuration flexible and reusable across environments.
Nested structures: where things get interesting
Terraform allows nesting objects inside maps or lists, which unlocks powerful patterns.
Here’s a more realistic variable:
1variable "services" {
2 type = map(object({
3 image = string
4 port = number
5 env = map(string)
6 }))
7}Example input:
1services = {
2 api = {
3 image = "node:18"
4 port = 3000
5 env = {
6 NODE_ENV = "production"
7 }
8 }
9 web = {
10 image = "nginx:latest"
11 port = 80
12 env = {}
13 }
14}Now you can dynamically create resources:
1resource "docker_container" "service" {
2 for_each = var.services
3
4 name = each.key
5 image = each.value.image
6
7 ports {
8 internal = each.value.port
9 }
10}This pattern is extremely powerful for scaling infrastructure definitions.
Lists vs maps: choosing the right one
A common point of confusion is when to use lists vs maps.
- List: ordered collection, accessed by index
- Map: key-value pairs, accessed by key
Example list:
1variable "ports" {
2 type = list(number)
3 default = [80, 443]
4}Example map:
1variable "ports" {
2 type = map(number)
3 default = {
4 http = 80
5 https = 443
6 }
7}If you care about naming and clarity, maps are usually the better choice.
Validation rules: catching mistakes early
Complex variables become much safer when you add validation.
1variable "instance_count" {
2 type = number
3
4 validation {
5 condition = var.instance_count > 0
6 error_message = "Instance count must be greater than 0."
7 }
8}For objects:
1variable "app_config" {
2 type = object({
3 name = string
4 port = number
5 })
6
7 validation {
8 condition = var.app_config.port > 0 && var.app_config.port < 65536
9 error_message = "Port must be between 1 and 65535."
10 }
11}This prevents invalid infrastructure from being applied.
Optional attributes (Terraform 1.3+)
You don’t always want every field required. Terraform supports optional attributes in object types:
1variable "app_config" {
2 type = object({
3 name = string
4 port = number
5 debug = optional(bool, false)
6 })
7}This simplifies inputs while maintaining structure.
A common mistake developers make
Overcomplicating variables too early.
It’s tempting to build deeply nested structures for "future flexibility," but that often leads to:
- Hard-to-read configurations
- Difficult debugging
- Poor module usability
A better approach:
- Start simple
- Refactor into complex structures when patterns emerge
- Keep module inputs intuitive
Real-world pattern: environment-based configs
Here’s a practical use case combining everything:
1variable "environments" {
2 type = map(object({
3 instance_type = string
4 min_size = number
5 max_size = number
6 }))
7}Example:
1environments = {
2 dev = {
3 instance_type = "t3.micro"
4 min_size = 1
5 max_size = 2
6 }
7 prod = {
8 instance_type = "t3.large"
9 min_size = 3
10 max_size = 10
11 }
12}This allows you to reuse the same module across environments with different scaling rules.
Wrapping up
Complex variables in Terraform are less about syntax and more about modeling infrastructure cleanly. Once you start using objects, maps, and nested structures effectively, your configurations become:
- More readable
- Easier to scale
- Safer to modify
If your Terraform files feel repetitive or fragile, that’s usually a sign it’s time to introduce structured variables.