NovuSpark
All articles
TerraformOctober 31, 2025 · NovuSpark Team

Mastering Terraform Variables and Outputs

This is the second post in our Terraform fundamentals series. If you haven't already, start with Terraform 101.

The configuration from our last post worked, but it only worked for one specific instance, in one specific region, with one specific AMI hardcoded directly into the resource block. Copy that file to stand up a second environment, and you're now maintaining two nearly-identical files that will inevitably drift apart the first time someone updates one and forgets the other.

Variables and outputs are the fix — and they're also where most of the actual design decisions in a Terraform codebase live. A configuration with well-designed variables reads like a clear interface; one without them reads like a script someone has to mentally trace through before they can safely change anything.

Input variables: parameterizing configuration

A variable is declared in its own block, and referenced anywhere in your configuration with var.<name>.

variable "instance_type" {
  type        = string
  description = "EC2 instance type for the web server"
  default     = "t3.micro"
}
 
variable "environment" {
  type        = string
  description = "Deployment environment name"
 
  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "environment must be one of: dev, staging, production."
  }
}
 
resource "aws_instance" "web" {
  ami           = "ami-0c1a7f89451184c8b"
  instance_type = var.instance_type
 
  tags = {
    Name        = "web-${var.environment}"
    Environment = var.environment
  }
}

A few details here matter more than they look:

  • type is not optional in practice, even though Terraform will accept a variable without one. An untyped variable accepts anything, which means a typo ("tru" instead of true) fails at apply time deep inside a provider, with a confusing error, instead of failing immediately and clearly at plan time.
  • validation blocks turn a bad input into a clear error message instead of a confusing downstream failure. Without the block above, passing environment = "prod" (instead of "production") wouldn't fail until something else broke because of the mismatch — possibly much later, and possibly in someone else's PR.
  • default is a design decision, not just a convenience. A variable with no default is required at every call site — good for values that genuinely differ per environment (like environment itself). A sensible default is appropriate for values most callers shouldn't have to think about.

Beyond strings: collection and structural types

Real configurations rarely stay at simple scalar variables for long. Terraform's type system covers collections and structured objects too, and choosing the right one is what keeps a growing variable set readable instead of turning into a pile of loosely-related flat variables.

variable "allowed_cidr_blocks" {
  type        = list(string)
  description = "CIDR blocks permitted to reach the load balancer"
  default     = ["10.0.0.0/8"]
}
 
variable "tags" {
  type        = map(string)
  description = "Common tags applied to every resource in this configuration"
  default     = {}
}
 
variable "server_config" {
  type = object({
    instance_type = string
    disk_size_gb  = number
    monitoring    = optional(bool, false)
  })
  description = "Structured server sizing configuration"
}

list(string) and map(string) cover the common cases — a set of CIDR blocks, a set of tags. object({...}) is worth knowing specifically because it lets you group genuinely related values into one variable instead of five separately-named ones that all have to be passed together anyway. optional(bool, false) inside an object type marks monitoring as not required, defaulting to false if the caller omits it — a level of precision flat variables can't express at all.

resource "aws_instance" "web" {
  ami           = "ami-0c1a7f89451184c8b"
  instance_type = var.server_config.instance_type
 
  tags = merge(var.tags, {
    Name = "web-${var.environment}"
  })
}

merge() here combines the common var.tags map with a resource-specific tag — a small but genuinely useful pattern once an organization standardizes on tagging for cost allocation or compliance, since it means every resource automatically picks up organization-wide tags without every module or configuration having to redeclare them.

Setting variable values

Terraform looks for values in a defined precedence order, which is worth knowing so you're never surprised by which one "wins":

  1. Command-line -var or -var-file flags (highest precedence)
  2. *.auto.tfvars files, loaded automatically
  3. TF_VAR_<name> environment variables
  4. The default in the variable block itself (lowest precedence)

In practice, most teams settle on one primary mechanism per context — a terraform.tfvars file for local development, and CI-injected TF_VAR_ environment variables for anything that shouldn't be committed to version control (this matters a lot once secrets enter the picture — more on that in the state management post).

# terraform.tfvars
environment   = "staging"
instance_type = "t3.small"
terraform apply -var-file="terraform.tfvars"
1. -var / -var-file flags on the command line2. *.auto.tfvars files (loaded automatically)3. TF_VAR_<name> environment variables4. default in the variable blockwinsfallback
Fig. 1 — where Terraform looks for a variable's value, highest precedence first

Outputs: exposing values for the next step

An output surfaces a value after apply completes — either for a human to read, or for another Terraform configuration (or CI pipeline step) to consume.

output "instance_public_ip" {
  value       = aws_instance.web.public_ip
  description = "Public IP address of the web server"
}
 
output "instance_id" {
  value       = aws_instance.web.id
  description = "EC2 instance ID"
}

Run terraform apply, and these values print at the end:

Outputs:

instance_id       = "i-0abcd1234efgh5678"
instance_public_ip = "18.130.42.101"

They're also queryable independently, without a full apply:

terraform output instance_public_ip

This is the detail that makes outputs genuinely useful rather than just a nicer print statement: a deployment script in CI can run terraform output -json and pipe real infrastructure values — a load balancer DNS name, a database endpoint, a queue URL — directly into the next step of a pipeline, without anyone copy-pasting a value out of a console.

DB_ENDPOINT=$(terraform output -raw db_endpoint)
kubectl set env deployment/api DATABASE_HOST="$DB_ENDPOINT"

That's a genuinely common pattern in practice: Terraform provisions the infrastructure, and its outputs become the input to whatever configures the application layer on top of it — a Kubernetes deployment, an Ansible inventory (as covered in our Ansible series), or a GitHub Actions deployment step.

Marking sensitive outputs

output "db_password" {
  value     = random_password.db.result
  sensitive = true
}

sensitive = true stops Terraform printing the value in plan and apply output. It is not encryption, and it does not remove the value from the state file — it only prevents it showing up in a terminal log or CI output where someone might screenshot it or it might get archived somewhere it shouldn't. Real secrets still need real secrets management; we'll come back to exactly why state itself needs protecting in the remote state post.

It's worth being explicit about what sensitive actually changes, because the name invites overconfidence:

Changes to Outputs:
  + db_password = (sensitive value)

That's the entire effect — a redacted display. terraform output db_password without -raw still prints the redacted placeholder, but terraform output -raw db_password prints the real value in plaintext, by design, because something downstream (a script, a CI step) usually needs the actual value. Treat sensitive as a display-hygiene feature, not an access-control feature.

A pattern worth adopting early: locals

Once a configuration has more than a couple of variables, you'll often want a computed value derived from them, without turning it into another input the caller has to think about. That's what locals are for:

locals {
  name_prefix = "novuspark-${var.environment}"
 
  common_tags = merge(var.tags, {
    ManagedBy   = "terraform"
    Environment = var.environment
  })
}
 
resource "aws_instance" "web" {
  ami           = "ami-0c1a7f89451184c8b"
  instance_type = var.instance_type
 
  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-web"
  })
}

locals aren't set by the caller — they're internal helper values computed once, referenced with local.<name> throughout the rest of the file. Reaching for a local instead of repeating a string-interpolation expression or a merge() call five times is one of the simplest ways to keep a growing configuration readable, and it means a naming-convention change (like the name_prefix format) only needs to happen in one place.

What to actually remember from this post

  • Type and validate every variable that isn't purely internal — it turns a confusing failure three steps later into a clear one immediately.
  • Reach for object and collection types once related values start traveling together — a well-designed variable interface groups what belongs together instead of scattering flat, loosely-related variables.
  • Understand the precedence order for variable values before you're debugging why a -var flag isn't taking effect.
  • Outputs are an integration point, not just a print statement — they're how Terraform hands real values to whatever comes next, whether that's a script, a CI pipeline, or another tool entirely.
  • sensitive = true hides a value from logs, it doesn't secure it — the value is still sitting in state in plaintext, and -raw still prints it.

Next in the series: Terraform Modules: Building Reusable Infrastructure, where this single-file configuration becomes a reusable building block.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.