Terraform input variables are named values that let you parameterize your infrastructure code. Instead of hardcoding settings like AWS region, instance type, environment name, or subnet CIDR ranges directly into resources, you define variables and then pass values into them when Terraform runs.
Think of a Terraform module like a function in a programming language. A function becomes reusable because it accepts parameters. Terraform modules work the same way. Input variables are the parameters of your infrastructure code.
Without variables, you might write something like this:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
tags = {
Name = "dev-web-1"
Environment = "dev"
}
}This works, but it is rigid. If you want a staging environment that uses t3.small, or a production environment that uses t3.medium, you have to edit the file itself. That is fragile and inconvenient.
With variables, the configuration becomes reusable:
variable "instance_type" {
description = "EC2 instance type for the web server"
type = string
default = "t3.micro"
}
variable "environment" {
description = "Deployment environment name"
type = string
}
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = var.instance_type
tags = {
Name = "${var.environment}-web-1"
Environment = var.environment
}
}Now the same code can be used in development, staging, and production by passing different values.
Terraform input variables solve several real-world problems for DevOps teams:
A single configuration can be reused in different environments. For example:
dev uses smaller instancesstaging uses similar settings to productionproduction uses larger instances and more replicasHardcoded values scattered across resources are hard to maintain. Variables pull those decisions into one obvious place.
Instead of editing .tf files every time a value changes, teams can provide values using .tfvars files, environment variables, or CI/CD pipeline secrets.
If you create modules later, variables become the public interface of the module. A good module clearly defines what callers can customize.
Variables let you describe expected inputs, enforce types, and reject invalid values before Terraform touches real infrastructure.
variable BlocksA Terraform input variable is declared with a variable block:
variable "region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}The label after variable is the variable name. You reference it elsewhere using var.<name>.
provider "aws" {
region = var.region
}| Argument | Purpose |
|---|---|
description | Explains what the variable is for |
type | Restricts the kind of value Terraform accepts |
default | Makes the variable optional by providing a fallback value |
sensitive | Hides the value in CLI output |
validation | Adds custom rules beyond the basic type check |
A well-defined variable block usually contains at least description and type, and often default if the value should be optional.
A variable is required when it has no default value.
variable "project_name" {
description = "Project identifier used in resource names"
type = string
}If you run Terraform without providing a value, Terraform will stop and ask for one, or fail in automation.
A variable is optional when it has a default.
variable "enable_monitoring" {
description = "Enable CloudWatch detailed monitoring"
type = bool
default = false
}If the caller does not supply a value, Terraform uses false.
Terraform supports rich type constraints. Defining types is important because it catches mistakes early and makes your configuration self-documenting.
stringA string is plain text.
variable "environment" {
description = "Environment name such as dev, staging, or prod"
type = string
}Example use:
resource "aws_s3_bucket" "logs" {
bucket = "${var.environment}-application-logs"
}Typical string values include:
numberA number is used for integer or decimal values.
variable "instance_count" {
description = "Number of EC2 instances to create"
type = number
default = 2
}Example use:
resource "aws_instance" "web" {
count = var.instance_count
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
}Common number values include:
boolA boolean can only be true or false.
variable "enable_versioning" {
description = "Enable S3 versioning"
type = bool
default = true
}Example use:
resource "aws_s3_bucket_versioning" "example" {
bucket = aws_s3_bucket.example.id
versioning_configuration {
status = var.enable_versioning ? "Enabled" : "Suspended"
}
}Boolean variables are especially useful for optional features.
listA list is an ordered collection of values of the same type.
variable "availability_zones" {
description = "AZs used for public subnets"
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}Example use:
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[count.index]
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
}Lists are common when you need:
mapA map stores key-value pairs of the same value type.
variable "tags" {
description = "Common tags applied to all resources"
type = map(string)
default = {
ManagedBy = "Terraform"
Owner = "platform-team"
}
}Example use:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
tags = var.tags
}Maps are very useful for:
For example:
variable "instance_sizes" {
type = map(string)
default = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.medium"
}
}Then you can do:
instance_type = var.instance_sizes[var.environment]objectAn object groups multiple named attributes, each with its own type.
variable "app_config" {
description = "Application infrastructure settings"
type = object({
instance_type = string
instance_count = number
enable_https = bool
root_disk_size = number
})
}Example value:
app_config = {
instance_type = "t3.small"
instance_count = 2
enable_https = true
root_disk_size = 30
}Example use:
resource "aws_instance" "app" {
count = var.app_config.instance_count
ami = "ami-0abcdef1234567890"
instance_type = var.app_config.instance_type
}Objects are excellent when related settings belong together.
tupleA tuple is an ordered sequence where each position can have a different type.
variable "maintenance_window" {
description = "Day, start hour, and whether the window is enabled"
type = tuple([string, number, bool])
default = ["Sunday", 2, true]
}Example access:
locals {
maintenance_day = var.maintenance_window[0]
maintenance_hour = var.maintenance_window[1]
maintenance_enabled = var.maintenance_window[2]
}Tuples are supported, but they are less common in beginner Terraform code. In many cases, an object is easier to read because attribute names are clearer than positional indexes.
Here is a more complete example for a small web application:
variable "project_name" {
description = "Project name used for tagging and naming"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
variable "region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "instance_count" {
description = "Number of EC2 instances"
type = number
default = 2
}
variable "subnet_ids" {
description = "Subnets where the app instances will run"
type = list(string)
}
variable "tags" {
description = "Additional resource tags"
type = map(string)
default = {}
}This is the kind of interface you will see in well-written Terraform modules.
Type constraints are useful, but sometimes they are not enough. A value can have the right type and still be invalid.
For example:
That is where validation blocks help.
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}If someone sets environment = "qa", Terraform fails before making changes.
variable "root_disk_size" {
description = "Size of the root volume in GB"
type = number
default = 20
validation {
condition = var.root_disk_size >= 8 && var.root_disk_size <= 200
error_message = "root_disk_size must be between 8 and 200 GB."
}
}variable "project_name" {
description = "Lowercase project name used in resource names"
type = string
validation {
condition = can(regex("^[a-z0-9-]+$", var.project_name))
error_message = "project_name may contain only lowercase letters, numbers, and hyphens."
}
}variable "allowed_ports" {
description = "List of inbound ports"
type = list(number)
default = [80, 443]
validation {
condition = alltrue([for p in var.allowed_ports : p > 0 && p < 65536])
error_message = "All allowed_ports values must be valid TCP/UDP ports between 1 and 65535."
}
}Validation is one of the simplest ways to make Terraform safer for teams. It turns vague mistakes into clear, actionable errors.
Declaring a variable only defines the interface. You still need a way to provide actual values.
Terraform supports multiple input methods.
terraform.tfvarsIf a file named terraform.tfvars exists in the root module directory, Terraform automatically loads it.
project_name = "billing-app"
environment = "dev"
instance_type = "t3.micro"
instance_count = 1This is convenient for local development or small demos.
*.auto.tfvarsAny file ending in .auto.tfvars or .auto.tfvars.json is also loaded automatically.
Example:
# dev.auto.tfvars
project_name = "billing-app"
environment = "dev"
instance_type = "t3.micro"# prod.auto.tfvars
project_name = "billing-app"
environment = "prod"
instance_type = "t3.medium"
instance_count = 3These files are useful when you want separate environment-specific values.
-var flagYou can pass individual values directly on the command line:
terraform plan -var="environment=staging" -var="instance_count=2"This is useful for quick experiments, but it becomes hard to manage when many variables are involved.
-var-file flagYou can explicitly tell Terraform which variable file to load:
terraform plan -var-file="dev.tfvars"
terraform apply -var-file="prod.tfvars"This is a common pattern for different environments:
terraform plan -var-file="environments/dev.tfvars"
terraform plan -var-file="environments/staging.tfvars"
terraform plan -var-file="environments/prod.tfvars"TF_VAR_Terraform also reads environment variables prefixed with TF_VAR_.
export TF_VAR_environment="prod"
export TF_VAR_instance_type="t3.medium"
export TF_VAR_db_password="SuperSecretPassword"
terraform applyThe variable name after TF_VAR_ must match the Terraform variable name.
This is especially useful in CI/CD pipelines because secrets can be injected from the platform instead of stored in files.
A practical beginner-friendly pattern is:
default for safe, common values*.tfvars or -var-file for environment-specific settingsTF_VAR_ for secrets in CI/CD-var only for quick one-off overridesSome variables contain secrets such as:
Mark them as sensitive:
variable "db_password" {
description = "Database administrator password"
type = string
sensitive = true
}When Terraform shows a plan or apply output, it hides the value:
password = (sensitive value)That reduces accidental exposure in terminal logs and screenshots.
sensitive = true hides the value in CLI output, but it does not remove the value from Terraform state. If a provider stores the secret in state, the state file can still contain it. That is one reason remote state security matters.
variable "bucket_name" {
description = "Globally unique S3 bucket name"
type = string
validation {
condition = length(var.bucket_name) >= 3 && length(var.bucket_name) <= 63
error_message = "bucket_name must be between 3 and 63 characters."
}
}
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "enable_versioning" {
description = "Whether versioning should be enabled"
type = bool
default = true
}
variable "extra_tags" {
description = "Additional tags to merge with defaults"
type = map(string)
default = {}
}
resource "aws_s3_bucket" "logs" {
bucket = var.bucket_name
tags = merge(
{
Name = var.bucket_name
Environment = var.environment
ManagedBy = "Terraform"
},
var.extra_tags
)
}This example shows good Terraform design:
Terraform can infer types in some cases, but explicit type constraints are clearer and safer.
-var flagsA few are fine. Many are painful. Prefer variable files for stable configurations.
.tfvars filesNever commit real passwords or tokens to Git. Use secure secret injection in your CI/CD platform or secret manager.
If a value must be chosen intentionally, do not give it a default. Let Terraform force the caller to think about it.
Validation blocks catch bad inputs early and make module usage much friendlier.
Input variables are one of the foundations of real-world Terraform. They make the difference between a one-off script and reusable infrastructure as code. Once you understand variables, you can create modules that work across multiple environments, pass values cleanly from CI/CD systems, validate user input, and keep your Terraform code easier to maintain.
A simple way to remember them is this:
Mastering these basics will make every later Terraform topic, from modules to remote state, much easier.
<MCQExercise exercise={{ id: 'tf-input-vars-q1', title: 'Question 1: Required Variables', description: 'When is a Terraform input variable considered required?', options: [ { id: 'a', text: 'When it includes a description but no type' }, { id: 'b', text: 'When it has no default value' }, { id: 'c', text: 'When it is marked sensitive = true' }, { id: 'd', text: 'When it is defined inside variables.tf' }, ], correctId: 'b', explanation: 'A Terraform variable becomes required when you do not provide a default value. Terraform then expects the caller to supply a value through a tfvars file, CLI flag, environment variable, or prompt.', wrongExplanations: { a: 'Descriptions and types are useful, but they do not determine whether a variable is required. The presence or absence of a default does.', c: 'sensitive = true only affects how the value is displayed in CLI output. It does not make the variable required.', d: 'The file name does not determine whether a variable is required. Variables can be declared in any .tf file.', }, }} />
<MCQExercise exercise={{ id: 'tf-input-vars-q2', title: 'Question 2: Variable Validation', description: 'What is the main purpose of a validation block inside a variable declaration?', options: [ { id: 'a', text: 'To encrypt the variable before Terraform stores it in state' }, { id: 'b', text: 'To define a custom rule the value must satisfy before Terraform proceeds' }, { id: 'c', text: 'To convert one variable type into another automatically' }, { id: 'd', text: 'To make the variable available to child modules only' }, ], correctId: 'b', explanation: 'Validation blocks let you define additional rules with condition and error_message. They are used to reject invalid values early, even when the value has the correct Terraform type.', wrongExplanations: { a: 'Validation does not encrypt anything. It only checks whether the provided value is acceptable.', c: 'Terraform can perform type conversion in some situations, but validation blocks are not for conversion.', d: 'Validation has nothing to do with module visibility. It only checks input quality.', }, }} />
<MCQExercise exercise={{ id: 'tf-input-vars-q3', title: 'Question 3: Passing Values', description: 'Which method is commonly preferred for passing secrets like database passwords into Terraform in CI/CD pipelines?', options: [ { id: 'a', text: 'Hardcoding the password directly in main.tf' }, { id: 'b', text: 'Committing terraform.tfvars with the password to Git' }, { id: 'c', text: 'Using environment variables such as TF_VAR_db_password' }, { id: 'd', text: 'Storing the password in a local comment above the resource block' }, ], correctId: 'c', explanation: 'Environment variables with the TF_VAR_ prefix are commonly used in CI/CD because the pipeline platform can inject secrets securely without storing them in committed files.', wrongExplanations: { a: 'Hardcoding secrets in Terraform code is insecure and difficult to rotate.', b: 'Committed tfvars files can expose secrets to anyone with repository access and should be avoided for real credentials.', d: 'Comments are not a secret management mechanism. They would expose the value immediately.', }, }} />
From lowest to highest: the variable's default, then environment variables prefixed TF_VAR_, then terraform.tfvars, then *.auto.tfvars in alphabetical order, then -var-file on the command line, and finally -var, which wins over everything. Later sources override earlier ones on a per-variable basis, so a tfvars file setting one value leaves the rest at their defaults.
Do not put them in tfvars files that get committed. Mark the variable sensitive = true so its value is redacted from plan and apply output, then supply it through an environment variable, a CI secret, or, best, a data source that reads it from AWS Secrets Manager, SSM Parameter Store or Vault at plan time. Be aware sensitive only hides values from console output: they are stored in plain text in the state file, which is why state needs encryption and restricted access regardless.
A variable is an input from outside the configuration. Something a caller supplies, and which differs between environments. A local is a value computed inside the configuration from variables, resources or expressions, and it cannot be overridden from outside. The rule of thumb: if someone deploying needs to choose it, make it a variable; if it is derived logic you want to name once and reuse, make it a local.
A validation block inside a variable declaration runs a condition against the supplied value and fails with your error message if it does not hold. This catches bad input at plan time with a clear explanation, rather than producing a confusing provider error later. For example rejecting an environment name that is not one of dev, staging or prod, or an instance type outside an approved list. It is one of the cheapest ways to make a module pleasant for other people to use.
No. Over-parameterising is a common mistake that makes a module hard to use and hard to reason about, because a caller faces twenty decisions where three would do. Make something a variable when it genuinely differs between callers or environments; use a local for internal logic, and hardcode values that should not vary. A module with sensible defaults and a small required surface is far more usable than one exposing every attribute.
Learn how Terraform local values work, when to choose locals over input variables or data sources, and how a locals block removes repeated expressions.
Declare Terraform output values, mark them sensitive, read them with the terraform output command and JSON, expose module outputs and use them in CI/CD.
Understand why Terraform keeps state, what is inside it, how remote backends and locking make teamwork safe, and the state commands that rescue you.