Terraform
Best combination:
- Terraform + Docker
- Provisioning & Config Management: Terraform to provision servers and Ansible to configure each one
- Provisioning & Server templating: Terraform to provision servers and Packer to package apps as VM images
- Provisioning & Server templating & Orchestration: Terraform to provision servers and Packer to package apps as VM images and Docker/K8 agent installed in the VM images
- Ansible is a procedural language while Terraform is declarative. Procedural does not fully capture the state of the infrastructure and limits reusability.
[!NOTE] Listening on any port less than 1024 requires root user privileges. This is a security risk since any attacker who manages to compromise your server would get root privileges, too.
Basic Terraform commands
[!NOTE] - Make sure that the AWS cred (ACCESS_KEY & SECRET_ACCESS_KEY) are in the env. or; - Do the following:
- General syntax for creating a resource:
1) Create main.tf with the following: (means the infra will be deployed in us-east-1)
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "example" {
ami = "ami=3edh9eh12ihddss2"
instance_type = "t2.micro"
tags = {
Name = "terraform-ex"
}
}
2) terraform init: scans the code (the provider code is downloaded into a .terraform folder; better to add to .gitignore)
3) terraform plan: see all the changes to be performed
- +: added
- -: deleted
- ~: modified
4) terraform fmt: format the code
5) terraform validate: validate the code
6) terraform apply: apply the changes
7) Add the following to .gitignore:
- .terraform
- *.tfstate
- *.tfstate.backup
8) terraform show: show the current state
9) terraform destroy: to destroy everything created
Example 1 (deploying single web server)
1) Write a bash script (simple server returning Hello World):
2) Terraform code (for ec2):
resource "aws_instance" "example" {
ami = "ami-3e12uddd9u"
instance_type = "t2.micro"
vpc_security_group_ids = [aws_security_group.instance.id]
user_data = <<-EOF
#!/bin/bash
echo "Hello, World" > index.html
nohup busybox httpd -f -p 8080 &
EOF
user_data_replace_on_change = true
tags = {
Name = "tf-ex"
}
}
- <<-EOF & EOF are tf way of allowing multi line text without using \n
3) Terraform code (for sg):
resource "aws_security_group" "instance" {
name = "tf-ex-instance"
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
[!NOTE] We can reference other resources' attributes in tf by doing
<PROVIDER>_<TYPE>.<NAME>.<ATTRIBUTE>.
4) terraform graph: Terraform smart enough to create an implicit dependency and creates a dependency graph to determine in which order it should create resources. Here, SG before EC2.
5) terraform apply
6) curl <public-ip>:8080 to get the output
7) terraform destroy
Variables
- We can define vars using
variable: - If default is not provided, it is asked interactively.
- Then, we can use it as
var.instance_nameor set it somewhere inside as${var.instance_name}.
[!TIP] We can also pass it at runtime by:
terraform apply -var "instance_name=YetAnotherName"
Output
- We can have a defined output to the user using
output: - It will be printed as the output.
[!TIP] Get the aws profile being used by:
aws sts get-caller-identity
Terraform State
-
Working locally,
terraform.tfstatefile lives locally on our computer. But in real prod situation, we can run into problems like:- Shared storage for state files
- Locking state files (to prevent race condition)
- Isolating state files
- Secrets protection
-
Solution: Remote backends. Solves the above issues. Ex. we can use S3 as remote storage solution.
S3 as a remote storage
provider "aws" {
region = "us-east-2"
}
# bucket creation
resource "aws_s3_bucket" "terraform_state" {
bucket = "tf-up-and-running-state-jb9h2hd22d"
lifecycle {
prevent_destroy = true # prevent accidental deletion
}
}
# versioning
resource "aws_s3_bucket_versioning" "enabled" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# server side encryption
resource "aws_s3_bucket_server_side_encryption" "default" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# block public access
resource "aws_s3_bucket_public_access_block" "public_access" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# dynamoDB for locking (strongly consistent)
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-up-and-running-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID" # PRIMARY_KEY; exact spelling here
attribute {
name = "LockID"
type = "S"
}
}
After init and apply, add the following backend:
terraform {
backend "s3" {
bucket = <name>
key = "global/s3/terraform.tfstate" # location to store
region = <region>
dynamodb_table = <name>
encrypt = true
}
}
# output
output "s3_bucket_arn" {
value = aws_s3_bucket.terraform_state.arn
description = "The ARN of the S3 bucket"
}
output "dynamodb_table_name" {
value = aws_dynamodb_table.terraform_locks.name
description = "The name of the DynamoDB table"
}
Do init again.
Limitations with Terraform's backend
Creation
- Need a two-step process:
1) Write tf code to create S3 and DynamoDB with a local back-end
2) Then go back to tf code, add remote backend config to use newly created S3 and DynamoDB and run init again to copy our local state to S3
Deletion
- Go Reverse:
1) remove the backend config, rerun init to copy the tf state back to our local disk
2) run destroy to delete S3 and DynamoDB
No vars/ref The following doesn't work:
terraform {
backend "s3" {
bucket = var.bucket
region = var.region
dynamodb_table = var.dynamodb_table
key = "example/terraform.tfstate"
encrypt = true
}
}
-
We need to manually enter or the following 2 ways:
-
Partial configs: omit certain params from the
backendconfig in code and pass viabackend-configarg duringinit.- Create a seperate
backend.hcl: - And then:
terraform init -backend-config=backend.hcl
- Create a seperate
-
Terragrunt: an open source tool
-
Isolation
Can be achieved via: - Workspaces - File layout
Workspaces
- default is the default
- All the workspaces are stored in the same backend
- Not visible in the code or terminal, do the following.
terraform workspace show: name of default workspaceterraform workspace new example1: new workspaceterraform workspace list: list all workspacesterraform workspace select example1: switch workspaces
File Layout (much better way)

READ MORE ABOUT FILE LAYOUT AFTERWARDS
Terraform Modules
- Providers should only be configured in root modules and not in reusable modules.
- Syntax:
i.e. mention module by:
provider "aws" {
region = "us-east-1"
}
module "webserver_cluster" {
source = "../../../modules/services/webserver-cluster"
}
[!NOTE] Make sure that the specified values are not hardcoded into the modules. Otherwise, if we use modules multiple times, the same values will be initialized. To avoid that, configure the changing values to be variable so that they can be input at the runtime.
- After making the values as variables, provide them as:
Locals
- We can define variable in a module but then it can be modified by the user during runtime.
- We can use
localsto make the code more readable but also not allow the end user to change the value at runtime. They are not asked during runtime. - They allow us to assign name to any tf expression within the module. Use as:
local.<NAME>
Module output
- Module can return values using
output. - Can be accessed using:
module.<MODULE_NAME>.<OUTPUT_NAME>
File paths & Inline Blocks
File Paths
- By default, the relative path is the dir where terraform apply is being done. But it won't work in a module that's defined in a seperate folder.
- We use path reference for that. i.e.
- path.module: returns the fs path of the module where the exp is defined
- path.root: fs path of the root module
- path.cwd: current working dir
Module Versioning
- If both our staging and production environment are pointing to the same module folder, as soon as we make a change in that folder, it will affect both environments on the very next deployment.
- This sort of coupling makes it more difficult to test a change in staging without any chance of affecting production.
- A better approach is to create versioned modules so that we can use one version in staging (e.g., v0.0.2) and a different version in production (e.g., v0.0.1)
- Terraform supports various types of module sources:
local_pathsGit URLSMercurial URLSHTTP URLS
- Solution: Create a versioned module, put the code for the module in a seperate Git repo and version/tag it and set the
sourceparameter to that repo's url. - So, our code will spread out across (atleast) 2 repos:
- modules
- live (defines live infra that we are running)
- Then, the module becomes:
module "webserver_cluster" { source = "github.com/foo/modules/services/webserver-cluster?ref=v0.0.1" cluster_name = "webservers-stage" db_remote_state_bucket = "(YOUR_BUCKET_NAME)" db_remote_state_key = "stage/data-stores/mysql/terraform.tfstate" instance_type = "t2.micro" min_size = 2 max_size = 2 }
[!NOTE] Semantic Versioning: MAJOR.MINOR.PATCH (eg 1.0.4). - Changes happen when: - MAJOR: incompatible API changes - MINOR: add funcs in backward-compat manner - PATCH: backward-compat bug fixes
Conditionals
Loops
- Terraform offers:
- count: to loop over resources and modules
- for_each: to loop over resources, inline blocks within a source, and modules
- for: expression to loop over lists and maps
- for: string directive to loop overe lists and maps within a string