Terraform Cheatsheet

Resources

Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Resource Block Syntax

resource "<TYPE>" "<LOCAL_NAME>" {
  argument = value
}

<TYPE> is the provider prefix plus the object kind (aws_instance), and it determines which provider handles the block. <LOCAL_NAME> is yours to choose and only ever used inside Terraform. Together they form the address aws_instance.web.

Terraform builds its dependency graph from attribute references, so an explicit depends_on is rarely needed:

resource "aws_instance" "web" {
  subnet_id = aws_subnet.public.id       # implicit dependency
}

# <type>.<name>.<attribute>, and (known after apply) values are fine
output "public_ip" {
  value = aws_instance.web.public_ip
}

Every resource also accepts count, for_each, depends_on, provider and lifecycle. Those are covered in the Meta-Arguments and Lifecycle topic, along with dynamic blocks.

Common AWS Resources

# EC2 instance
resource "aws_instance" "web" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]
  key_name               = aws_key_pair.deployer.key_name
  tags                   = { Name = "web-server" }
}

# S3 bucket: modern providers split every setting into its own resource
resource "aws_s3_bucket" "assets" {
  bucket = "myapp-assets-${random_id.suffix.hex}"
}

resource "aws_s3_bucket_versioning" "assets" {
  bucket = aws_s3_bucket.assets.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket                  = aws_s3_bucket.assets.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Security group
resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Inline ingress/egress blocks and separate aws_vpc_security_group_ingress_rule resources fight each other. Pick one style per security group.

Sensitive Values

resource "aws_db_instance" "main" {
  identifier          = "myapp-db"
  engine              = "postgres"
  engine_version      = "17"
  instance_class      = "db.t3.medium"
  allocated_storage   = 20
  db_name             = "myapp"
  username            = var.db_user
  password            = var.db_password    # marked sensitive at the variable
  skip_final_snapshot = false
}

What sensitive does and does not do:

  • Providers mark some attributes sensitive themselves. Terraform then redacts them from plan and apply output and refuses to print them.
  • Sensitivity is contagious. Any expression touching a sensitive value becomes sensitive, which is why an innocent-looking tags map can suddenly print as (sensitive value).
  • It is a display control, not encryption. Sensitive values are written to state and to plan files in plaintext.
  • nonsensitive(x) strips the marker when you are certain the result is safe. Use it sparingly.

The only way to keep a secret out of state is to make it ephemeral.

Ephemeral Resources and Write-Only Arguments

An ephemeral block (1.10+) produces a value Terraform re-reads every phase and never persists to state or a plan file. A write-only argument (1.11+) accepts such a value on a normal resource and discards it after the API call.

ephemeral "random_password" "db" {
  length           = 20
  override_special = "!#%&*()-_=+[]{}<>:?"
}

resource "aws_db_instance" "example" {
  engine              = "postgres"
  instance_class      = "db.t3.micro"
  allocated_storage   = 5
  username            = "example"
  skip_final_snapshot = true

  password_wo         = ephemeral.random_password.db.result
  password_wo_version = 1     # bump this to push a new value
}
  • Write-only arguments are named by the provider, conventionally <attr>_wo, paired with <attr>_wo_version. Terraform sends the value only when the version changes.
  • Ephemeral values may be used in provider config, provisioners, other ephemeral resources, write-only arguments, and ephemeral = true variables and outputs. They may not be used in ordinary resource arguments, because that would persist them.
  • ephemeral "aws_secretsmanager_secret_version" is the usual way to read a secret at apply time without dragging it into state.

action Blocks (1.14+)

Actions are provider-defined operations that fall outside create, read, update, delete. Invoking one changes no state.

action "aws_lambda_invoke" "warm" {
  config {
    function_name = aws_lambda_function.api.function_name
    payload       = jsonencode({ warm = true })
  }
}

resource "aws_lambda_function" "api" {
  # ...
  lifecycle {
    action_trigger {
      events     = [after_create, after_update]
      actions    = [action.aws_lambda_invoke.warm]
      on_failure = halt         # halt (default) | continue | taint, 1.16+
    }
  }
}
terraform plan  -invoke=action.aws_lambda_invoke.warm   # plan just the action
terraform apply -invoke=action.aws_lambda_invoke.warm   # run just the action

action blocks accept config, count, for_each and provider. Inside config, the caller symbol holds the triggering resource object, so one action block can serve many resource instances. Available actions are entirely up to the provider, so check its registry docs.

List Resources and terraform query (1.14+)

List Resources search live infrastructure for objects Terraform does not manage. They live in *.tfquery.hcl files, kept separate from your configuration so a query never becomes part of a plan.

# unmanaged.tfquery.hcl
list "aws_instance" "prod" {
  provider = aws
  limit    = 50                 # default 100

  config {
    region = "us-east-2"
    filter {
      name   = "tag:Name"
      values = ["prod-*", "staging-*"]
    }
  }
}
terraform query                                        # print matches
terraform query -json                                  # machine-readable
terraform query -generate-config-out=found.tf          # import + resource blocks
terraform query -var 'env=prod'                        # query files take variables

Results are addressed list.<type>.<label>. Add include_resource = true to fetch full resource state per match, at a performance cost. See the Import and Refactoring topic for using the generated blocks.

Timeouts, Provisioners and terraform_data

resource "aws_db_instance" "main" {
  # ...
  timeouts {
    create = "60m"
    update = "30m"
    delete = "20m"
  }
}

timeouts is provider-defined, so which keys exist depends on the resource. It is not a meta-argument.

# terraform_data: a built-in resource, no extra provider needed (1.4+)
resource "terraform_data" "deploy" {
  triggers_replace = [aws_instance.web.id]   # replace when this changes
  input            = var.release_tag         # tracked, readable as .output

  provisioner "local-exec" {
    command = "./deploy.sh ${self.output}"
  }
}

Prefer terraform_data over null_resource, which needs hashicorp/null. Since 1.16 it also supports a store block that can hold ephemeral and sensitive values across plan and apply.

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  provisioner "remote-exec" {
    inline = ["sudo apt-get install -y nginx"]
    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }
}

Provisioners are a last resort, and HashiCorp says so in its own docs. They run only on create (or on destroy with when = destroy), never on update, they are invisible to plan, and a failure taints the resource. Use user_data, cloud-init, a baked image, or a configuration management tool instead.