Terraform

Everyday terminal commands

# downloads the providers into .terraform/ and writes the lock file.
terraform init

#Show us what will build, change or destroy
terraform plan

#Better plan. It will create a file called plan.out, that we can apply later.
terraform plan -out=plan.out

# Apply the changes
terraform apply plan.out

# Destroy everything
terraform destroy

Files inside a project

.terraform/ -> My code: the desired state
.terraform.lock.hcl -> Pins exact provider versions (like package-lock.json)
example.tf  -> My code: the desired state
plan.out    -> changes to be applied
terraform.tfstate -> Critical Mapping of your code to the real AWS resources. Good practice is to back it up in S3 with "terraform state s3"
terraform.tfstate.backup -> Previous version of the state, auto-created before each change

For .gitignore
.terraform/
*.tfstate
*.tfstate.*
plan.out

Example of terraform file for launching ec2 instance

# Configure the AWS Provider. AWS with region us-east-1. It can be any other provider like gcp, azure, etc.

provider "aws" {
  region = "us-east-1"
}

# A data source is a read-only lookup. Terraform doesn't create anything with it; it just asks AWS "give me information about something that already exists."
# I can use this thing later when creating resources
data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}
# This is the resouce that will be provisioned in AWS
resource "aws_instance" "example" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  tags = {
    Name = "Hello Terraform"
  }
}

Terraform state s3 – Backup of most critical file. Do not lose it with the machine hosting it

https://developer.hashicorp.com/terraform/language/backend/s3
Create a new file called: remote_state.tf 
terraform {
  backend "s3" {
    bucket = "mybucket"
    key    = "path/to/my/key"
    region = "us-east-1"
  }
}

Variables

Like parameters in cloudformation. If no default value is provided it will ask while terraform plan

variables.tf

variable "ec2_instance_type" {
  type = string
  default = "t3.micro"
}

inside main.tf

resource "aws_instance" "example" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.ec2_instance_type
}

How to override the values:
terraform apply -var="ec2_instance_type=t3.micro"

How to specify a lot of variables:

.tfvars files, for example prod.tfvars

ec2_instance_type = "t3.micro"

to tell terraform about this file

terraform plan -var-file=prod.tfvars

If you rename prod.tfvars to terraform.tfvars or prod.auto.tfvars, Terraform loads it without any flag -var-file=.

Outputs

An output block prints a value after apply and stores it in the state, so you can read it later with terraform output. Other Terraform configurations and modules can read it too.
The reference syntax is <resource_type>.<name>.<attribute>. Which attributes exist is listed in the provider docs under "Attribute Reference" for each resource.
outputs.tf
output "instance_id" {
  description = "ID of the EC2 instance"
  value       = aws_instance.example.id
}

output "instance_public_ip" {
  description = "Public IP of the EC2 instance"
  value       = aws_instance.example.public_ip
}

output "ami_used" {
  value = data.aws_ami.ubuntu.id
}

After terraform apply you'll see:
Outputs:

ami_used           = "ami-0abc123..."
instance_id        = "i-0123abcd..."
instance_public_ip = "54.12.34.56"

Locals

Named values computed INSIDE the code. Like variables in a shell script.
Can't be overridden from outside (unlike variables).
Example

Say you have 3 resources, each needing the same name prefix and tags:

resource "aws_instance" "web" {
  tags = {
    Name        = "myapp-dev-web"
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}

resource "aws_instance" "db" {
  tags = {
    Name        = "myapp-dev-db"
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}

resource "aws_security_group" "sg" {
  tags = {
    Name        = "myapp-dev-sg"
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}

To change dev to prod, you edit 6 places. Easy to miss one.
The same with locals

locals {
  env         = "dev"
  name_prefix = "myapp-${local.env}"

  common_tags = {
    Environment = local.env
    ManagedBy   = "terraform"
  }
}

resource "aws_instance" "web" {
  tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}

resource "aws_instance" "db" {
  tags = merge(local.common_tags, { Name = "${local.name_prefix}-db" })
}

resource "aws_security_group" "sg" {
  tags = merge(local.common_tags, { Name = "${local.name_prefix}-sg" })
}

Modules from terraform repo

Module for building a VPC : https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest

create a file vpc.tf with example from the page

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = true

  tags = {
    Terraform = "true"
    Environment = "dev"
  }
}

Create own module and use it

A module is just a folder containing .tf files. That’s the whole definition.Every Terraform project is already a module: the folder where you run terraform plan is called the root module.Any other folder you call from it is a child module.
Does a subfolder automatically mean it’s a module?
No. Terraform never guesses. A subfolder is a module only if some module block points at it with source. Terraform ignores subfolders otherwise: running plan in the root reads only the .tf files directly in that folder.
Convention is to put local modules under modules/:

my-project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
    └── ec2/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Create the module. For example modules/ec2/variables.tf

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

variable "name" {
  type = string          # no default = required input
}

modules/ec2/main.tf

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "this" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  tags = {
    Name = var.name
  }
}

modules/ec2/outputs.tf

output "instance_id" {
  value = aws_instance.this.id
}

output "public_ip" {
  value = aws_instance.this.public_ip
}

In the root main.tf, calling it. Take a close look how we place name and instance_type from module variables

module "web" {
  source = "./modules/ec2"

  # inputs = the module's variables
  name          = "web-server"
  instance_type = "t3.small"
}

module "db" {
  source = "./modules/ec2"      # same module, used a second time

  name = "db-server"            # instance_type uses the default
}

And to read a module’s outputs, in the root outputs.tf

output "web_ip" {
  value = module.web.public_ip      # module.<NAME>.<OUTPUT_NAME>
}