The configuration from part 1 works, but the region, the bucket prefix and the tags are all typed straight into main.tf. To create the same bucket for staging and production you would have to copy the file and edit it by hand, which is exactly the drift Terraform is supposed to prevent.

This is part 2 of Terraform from Zero. You will turn the hard-coded values into inputs, compute shared values once, and expose results that other tools and people can use.

The three building blocks

Terraform has a simple model that maps well onto a function in any programming language:

  • Input variables are the parameters: values supplied from outside.
  • Locals are local variables: values computed once inside the configuration.
  • Outputs are the return values: results you want to print or pass on.

A common convention is to keep them in variables.tf, locals.tf (or at the top of main.tf) and outputs.tf. Terraform loads every .tf file in the folder, so the split is only for humans.

Declare input variables

Create variables.tf:

hcl
variable "region" {
  description = "AWS region to deploy into."
  type        = string
  default     = "eu-west-2"
}

variable "environment" {
  description = "Deployment environment."
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

variable "versioning_enabled" {
  description = "Keep previous versions of every object."
  type        = bool
  default     = true
}

variable "extra_tags" {
  description = "Tags merged into every resource."
  type        = map(string)
  default     = {}
}

Always set type and description. The type catches mistakes such as passing "yes" where a boolean is expected, and the description shows up in prompts and documentation. A validation block turns a vague runtime failure into a clear message at plan time.

A variable without a default is required. If you run terraform plan without supplying environment, Terraform prompts for it interactively, which is fine locally and a failure in CI.

Use variables in the configuration

Reference a variable with var.<name>:

hcl
provider "aws" {
  region = var.region
}

resource "aws_s3_bucket" "notes" {
  bucket_prefix = "${local.name_prefix}-"
  tags          = local.common_tags
}

resource "aws_s3_bucket_versioning" "notes" {
  bucket = aws_s3_bucket.notes.id

  versioning_configuration {
    status = var.versioning_enabled ? "Enabled" : "Suspended"
  }
}

The "${...}" syntax interpolates a value into a string, and condition ? a : b is the conditional expression.

Compute shared values with locals

Tags and naming rules usually combine several inputs. Compute them once in a locals block:

hcl
locals {
  name_prefix = "field-notes-${var.environment}"

  common_tags = merge(
    {
      Project     = "terraform-from-zero"
      Environment = var.environment
      ManagedBy   = "terraform"
    },
    var.extra_tags,
  )
}

Reference locals with local.<name> (singular, even though the block is locals). merge combines maps, and later maps win, so a team can override a default tag through extra_tags.

Use locals for anything you would otherwise repeat. Do not turn every literal into a local, though; a value used once is easier to read where it is used.

Expose results with outputs

Create outputs.tf:

hcl
output "bucket_name" {
  description = "Generated bucket name."
  value       = aws_s3_bucket.notes.bucket
}

output "bucket_arn" {
  description = "ARN for IAM policies."
  value       = aws_s3_bucket.notes.arn
}

After terraform apply, Terraform prints the outputs. You can read them later without planning:

bash
terraform output
terraform output -raw bucket_name
terraform output -json

-raw is handy in shell scripts, and -json is the stable format for other tools. In part 4, outputs become the way a module hands values back to its caller.

Supply values: the order Terraform uses

Terraform accepts variable values from several places. When the same variable is set more than once, the later source in this list wins:

  1. The default in the variable block.
  2. Environment variables named TF_VAR_<name>, for example TF_VAR_environment=dev.
  3. A terraform.tfvars file, then terraform.tfvars.json.
  4. Any *.auto.tfvars or *.auto.tfvars.json files, in alphabetical order.
  5. -var-file=... and -var=... options on the command line, in the order given.

For environments, keep one file per environment:

hcl
# dev.tfvars
environment        = "dev"
versioning_enabled = false
extra_tags = {
  Owner = "platform-team"
}
bash
terraform plan -var-file=dev.tfvars

Commit tfvars files that only hold non-secret settings. Keep anything secret out of them, as the next section explains.

Handle secrets carefully

Mark a variable as sensitive when it holds a password or token:

hcl
variable "db_password" {
  description = "Master password for the database."
  type        = string
  sensitive   = true
}

Terraform then hides the value in plan and apply output, and any output that uses it must also be marked sensitive = true. Be clear about what this does not do: sensitive values are still written in plain text to the state file. Protect the state (part 3 encrypts it in S3) and prefer pulling secrets at apply time from a secret manager or a CI secret exposed as TF_VAR_db_password.

Terraform 1.10 and later also support ephemeral values, which are never stored in plan or state files. Check the ephemeral values documentation before relying on them, because only some resources and providers support them.

Inspect values with terraform console

When an expression does not behave as expected, open the console:

bash
terraform console -var-file=dev.tfvars
> local.common_tags
> upper(var.environment)

The console evaluates expressions against your configuration and current state, which is much faster than editing and re-planning.

Key takeaways

  • Variables are inputs, locals are computed values, outputs are results; split them across variables.tf, locals.tf and outputs.tf.
  • Always give variables a type and description, and use validation blocks for allowed values.
  • Use one .tfvars file per environment and pass it with -var-file; later sources override earlier ones.
  • sensitive = true hides values from output but not from state, so protect the state file.
  • Use terraform console to test expressions quickly.

Next in the series: part 3 moves state off your laptop into S3 with native locking, and shows how to import and move resources safely.