Terraform is an open source Infrastructure as Code (IaC) tool developed by HashiCorp. It lets you define your cloud infrastructure in declarative configuration files, then deploy and manage it reproducibly. Terraform supports more than 3000 providers (AWS, Azure, GCP, OVH, Docker, Kubernetes...) and is today the reference for IaC.
Prerequisites
- Operating system: Linux, macOS or Windows (this guide focuses on Linux and macOS)
- Basic knowledge: Command line, notions of cloud computing
- Cloud account: An AWS, Azure, GCP or other provider account (the examples use AWS)
- Text editor: VS Code with the HashiCorp Terraform extension recommended
- Git: To version your configuration files
Installation
Installation on Linux (Debian/Ubuntu)
Add the official HashiCorp repository and install Terraform:
# Add the HashiCorp GPG key
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
# Add the repository
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
# Install
sudo apt update && sudo apt install -y terraform
# Verify
terraform --version
Installation on macOS
# Via Homebrew
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Verify
terraform --version
Installation with tfenv (recommended)
tfenv is a version manager for Terraform, similar to nvm for Node.js. It makes it easy to switch between versions:
# Install tfenv
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Install a specific version
tfenv install 1.9.0
tfenv use 1.9.0
# List available versions
tfenv list-remote
Create a
.terraform-version file at the root of each project to pin the version. This ensures the whole team uses the same version of Terraform.
Fundamental concepts
Providers
Providers are plugins that allow Terraform to interact with the APIs of cloud services, SaaS or others. Each provider exposes resources and data sources.
# AWS provider configuration
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-3" # Paris
}
Resources
Resources are the main building blocks. Each resource describes a piece of infrastructure to create:
# Create an EC2 instance
resource "aws_instance" "web_server" {
ami = "ami-0a21d1c76ac56fee7"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
}
}
State
The state is a JSON file (terraform.tfstate) that records the mapping between your configuration files and the real resources in the cloud. It is the central mechanism that allows Terraform to know what already exists and what needs to be changed.
Plan and Apply
Terraform works in two key steps:
- Plan: Compares your configuration with the current state and shows the changes to apply (creation, modification, deletion)
- Apply: Actually executes the planned changes on your infrastructure
Always review the plan before applying changes. A thorough
terraform plan avoids accidental deletions of resources in production.
First project
Let's create a complete Terraform project that deploys an EC2 instance with a security group.
Project structure
mkdir my-first-project && cd my-first-project
# Recommended structure
touch main.tf variables.tf outputs.tf terraform.tfvars
main.tf file
# main.tf - Main configuration
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# Security group
resource "aws_security_group" "web_sg" {
name = "web-sg"
description = "Allow HTTP and SSH"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.admin_ip]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# EC2 instance
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web_sg.id]
tags = {
Name = "my-web-server"
}
}
Deployment commands
# Initialize the project (downloads the providers)
terraform init
# Check the syntax
terraform validate
# Preview the changes
terraform plan
# Apply the changes
terraform apply
# Apply without interactive confirmation (CI/CD)
terraform apply -auto-approve
# Destroy all the infrastructure
terraform destroy
Variables and Outputs
Define variables (variables.tf)
# variables.tf
variable "aws_region" {
description = "AWS region for the deployment"
type = string
default = "eu-west-3"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "The instance type must be t3.micro, t3.small or t3.medium."
}
}
variable "ami_id" {
description = "AMI of the EC2 instance"
type = string
}
variable "admin_ip" {
description = "Administrator IP for SSH (CIDR format)"
type = string
sensitive = false
}
Values file (terraform.tfvars)
# terraform.tfvars - Variable values
aws_region = "eu-west-3"
instance_type = "t3.micro"
ami_id = "ami-0a21d1c76ac56fee7"
admin_ip = "203.0.113.50/32"
Outputs (outputs.tf)
# outputs.tf - Output values
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "public_ip" {
description = "Public IP of the server"
value = aws_instance.web.public_ip
}
output "security_group_id" {
description = "ID of the security group"
value = aws_security_group.web_sg.id
}
You can also pass variables via the environment by prefixing them with
TF_VAR_. For example: export TF_VAR_aws_region="eu-west-3". This is the recommended method in CI/CD.
State management
Local vs remote state
By default, Terraform stores the state locally in terraform.tfstate. In a team, you must use a remote backend to avoid conflicts and data loss.
S3 backend with DynamoDB (recommended for AWS)
# backend.tf - Remote backend configuration
terraform {
backend "s3" {
bucket = "mon-projet-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-west-3"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
Create the backend resources
# Create the S3 bucket for the state
aws s3api create-bucket \
--bucket mon-projet-terraform-state \
--region eu-west-3 \
--create-bucket-configuration LocationConstraint=eu-west-3
# Enable versioning on the bucket
aws s3api put-bucket-versioning \
--bucket mon-projet-terraform-state \
--versioning-configuration Status=Enabled
# Create the DynamoDB table for state locking
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
State locking
State locking prevents two users from running terraform apply simultaneously. DynamoDB manages this lock automatically. If a lock stays stuck:
# Force the unlock (use with caution)
terraform force-unlock LOCK_ID
The
terraform.tfstate file may contain passwords, API keys and other secrets in clear text. Never commit it to Git. Add it to your .gitignore and use an encrypted backend.
Modules
Modules are reusable containers for groups of resources. They let you organize your code, avoid duplication and standardize deployments.
Create a module
# Structure of a module
modules/
ec2-instance/
main.tf
variables.tf
outputs.tf
# modules/ec2-instance/main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = var.instance_name
Environment = var.environment
}
}
# modules/ec2-instance/variables.tf
variable "ami_id" {
type = string
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "instance_name" {
type = string
}
variable "environment" {
type = string
default = "dev"
}
# modules/ec2-instance/outputs.tf
output "instance_id" {
value = aws_instance.this.id
}
output "public_ip" {
value = aws_instance.this.public_ip
}
Use a module
# main.tf - Module call
module "web_server" {
source = "./modules/ec2-instance"
ami_id = "ami-0a21d1c76ac56fee7"
instance_type = "t3.small"
instance_name = "web-production"
environment = "prod"
}
module "api_server" {
source = "./modules/ec2-instance"
ami_id = "ami-0a21d1c76ac56fee7"
instance_type = "t3.medium"
instance_name = "api-production"
environment = "prod"
}
# Access the module outputs
output "web_ip" {
value = module.web_server.public_ip
}
Registry modules
The Terraform Registry offers community and official modules ready to use:
# Use a module from the registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
name = "mon-vpc"
cidr = "10.0.0.0/16"
azs = ["eu-west-3a", "eu-west-3b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
}
Provisioners
Provisioners run scripts or commands after a resource is created. They should be used as a last resort; prefer dedicated tools (Ansible, cloud-init) when possible.
local-exec
Runs a command on the machine that launches Terraform:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo ${self.public_ip} >> inventory.txt"
}
}
remote-exec
Runs commands directly on the created resource:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
key_name = "ma-cle-ssh"
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt update",
"sudo apt install -y nginx",
"sudo systemctl enable nginx"
]
}
}
Provisioners are a last resort. HashiCorp recommends using
cloud-init (via user_data) for bootstrapping instances and Ansible or Chef for configuration. Provisioners are not tracked in the state and make the code less predictable.
Workspaces
Workspaces let you manage several environments (dev, staging, prod) with the same configuration by isolating the state of each one.
# List the workspaces
terraform workspace list
# Create a workspace
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# Switch workspace
terraform workspace select prod
# Show the active workspace
terraform workspace show
Use the workspace in the configuration
# Adapt the configuration depending on the environment
locals {
instance_types = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.medium"
}
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = local.instance_types[terraform.workspace]
tags = {
Name = "web-${terraform.workspace}"
Environment = terraform.workspace
}
}
Secrets management
Sensitive variables
# Mark a variable as sensitive
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
resource "aws_db_instance" "main" {
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
username = "admin"
password = var.db_password
}
Environment variables
# Pass secrets via the environment
export TF_VAR_db_password="my_secure_password"
terraform apply
Integration with HashiCorp Vault
# Retrieve a secret from Vault
provider "vault" {
address = "https://vault.example.com:8200"
}
data "vault_generic_secret" "db_creds" {
path = "secret/data/database"
}
resource "aws_db_instance" "main" {
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
username = data.vault_generic_secret.db_creds.data["username"]
password = data.vault_generic_secret.db_creds.data["password"]
}
Always add
*.tfvars and terraform.tfstate* to your .gitignore. In CI/CD, use the native secret managers of your platform (GitHub Secrets, GitLab CI Variables, AWS Secrets Manager).
Best practices
Recommended project structure
projet-terraform/
environments/
dev/
main.tf
backend.tf
terraform.tfvars
staging/
main.tf
backend.tf
terraform.tfvars
prod/
main.tf
backend.tf
terraform.tfvars
modules/
vpc/
ec2/
rds/
.gitignore
README.md
Naming convention
- Files: Use descriptive names (
main.tf,variables.tf,outputs.tf,backend.tf) - Resources: snake_case naming, prefix by the logical type (
web_server,api_gateway) - Variables: Descriptive names with a mandatory
descriptionand an explicittype - Tags: Standardize the tags (Name, Environment, Team, Project, Cost-Center)
Module versioning
# Always pin the version of providers and modules
terraform {
required_version = ">= 1.5.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0" # Exact version in production
}
.gitignore for Terraform
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!example.tfvars
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc
Troubleshooting
Validation and formatting
# Validate the syntax of the configuration
terraform validate
# Format the files according to the standard style
terraform fmt
# Format recursively
terraform fmt -recursive
# Check the formatting (CI/CD)
terraform fmt -check
Debug mode
# Enable debug logs
export TF_LOG=DEBUG
terraform plan
# Available levels: TRACE, DEBUG, INFO, WARN, ERROR
# Redirect the logs to a file
export TF_LOG_PATH="terraform-debug.log"
terraform apply
State surgery (advanced operations)
# List the resources in the state
terraform state list
# Show the details of a resource
terraform state show aws_instance.web
# Move a resource (rename)
terraform state mv aws_instance.web aws_instance.web_server
# Remove a resource from the state (without destroying it)
terraform state rm aws_instance.web
# Import an existing resource into the state
terraform import aws_instance.web i-0abc123def456
# Refresh the state from the real infrastructure
terraform refresh
The
terraform state commands modify the state directly. Always create a backup before any operation: cp terraform.tfstate terraform.tfstate.backup. With a remote backend, S3 versioning acts as a safety net.
Conclusion
Terraform is a powerful tool that transforms infrastructure management by making it declarative, reproducible and versionable. In summary, here are the key points to remember:
- Declare your infrastructure in HCL files versioned with Git
- Use modules to create reusable and standardized components
- Secure the state with an encrypted remote backend and state locking
- Isolate environments with workspaces or separate directories
- Protect secrets with sensitive variables and a secrets manager
- Always validate with
terraform planbefore eachapply
Explore Terraform Cloud for team collaboration, advanced modules with
for_each and dynamic, and integration into your CI/CD pipelines (GitHub Actions, GitLab CI). The official HashiCorp documentation is excellent and constantly updated.
Comments