Devops
Difficulty: Intermediate
11 min read

Terraform: Infrastructure as Code for beginners

Learn how to automate the deployment of your cloud infrastructure with Terraform, the reference IaC tool created by HashiCorp.

Back to tutorials
What is Terraform?
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
tfenv tip
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
Important
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
}
Environment variables
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
State security
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"
    ]
  }
}
Warning
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"]
}
Secrets best practices
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 description and an explicit type
  • 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
Precaution
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 plan before each apply
Going further
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.

Written by

Morgann Riu

Cybersecurity and Linux administration expert. I share my knowledge through free tutorials and training to help system administrators and developers secure their infrastructures.

Share this tutorial

Did you enjoy this article?

Comments

Checklist Sécurité Linux

30 points essentiels pour sécuriser un serveur Linux. Recevez aussi les nouveaux tutoriels par email.

Pas de spam. Désabonnement en 1 clic.