Terraform Provisioners
Introduction
Terraform provisioners provide a way to execute scripts or commands on local or remote resources as part of your infrastructure deployment. While Terraform primarily focuses on declarative infrastructure management, provisioners let you handle configuration tasks that might fall outside of standard resource creation and modification.
Provisioners are a powerful feature, but they should be used sparingly and as a last resort. HashiCorp, the creator of Terraform, recommends using purpose-built tools like Ansible, Chef, or Puppet for configuration management whenever possible.
Types of Provisioners
Terraform offers several built-in provisioner types:
local-exec
: Executes a command on the machine running Terraformremote-exec
: Executes a command on a remote resource (e.g., an EC2 instance)file
: Copies files or directories from the machine running Terraform to the remote resource
Let's explore each type with examples.
The local-exec
Provisioner
The local-exec
provisioner executes commands on the local machine where Terraform is running. This is useful for tasks like updating local inventory files, running local scripts, or triggering other local processes.
Example: Creating a local inventory file
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "echo ${self.public_ip} > ip_address.txt"
}
}
In this example, after AWS creates the EC2 instance, Terraform executes a local command that writes the instance's public IP address to a file named ip_address.txt
.
Example: Running a more complex local script
resource "aws_instance" "app" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
provisioner "local-exec" {
command = <<-EOT
echo "Instance ID: ${self.id}"
echo "Public IP: ${self.public_ip}"
echo "Private IP: ${self.private_ip}"
./update_inventory.sh ${self.public_ip} app_server
EOT
}
}
This example runs multiple commands including a local script, passing the instance's public IP as a parameter.
The remote-exec
Provisioner
The remote-exec
provisioner executes commands on a remote resource after it's created. This requires a connection block to specify how Terraform should connect to the remote resource.
Example: Installing software on an EC2 instance
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
key_name = "my-key-pair"
# Security group configuration omitted for brevity
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/my-key-pair.pem")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo yum update -y",
"sudo yum install -y httpd",
"sudo systemctl start httpd",
"sudo systemctl enable httpd",
"echo '<html><body><h1>Hello from Terraform!</h1></body></html>' | sudo tee /var/www/html/index.html"
]
}
}
This example:
- Creates an EC2 instance
- Connects to it via SSH
- Runs commands to install and configure Apache web server
- Creates a simple HTML file
The file
Provisioner
The file
provisioner copies files or directories from the machine running Terraform to the remote resource.
Example: Copying a configuration file
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
key_name = "my-key-pair"
# Security group configuration omitted for brevity
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/my-key-pair.pem")
host = self.public_ip
}
provisioner "file" {
source = "files/nginx.conf"
destination = "/tmp/nginx.conf"
}
provisioner "remote-exec" {
inline = [
"sudo mv /tmp/nginx.conf /etc/nginx/nginx.conf",
"sudo systemctl restart nginx"
]
}
}
This example:
- Creates an EC2 instance
- Copies a local Nginx configuration file to the remote instance
- Moves the file to the correct location and restarts Nginx
Provisioner Timing
Terraform provisioners can run at different times during the resource lifecycle:
- Creation-time provisioners (default): Run when the resource is created
- Destroy-time provisioners: Run before the resource is destroyed
Example: Destroy-time provisioner
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
# Creation-time provisioner
provisioner "local-exec" {
command = "echo 'Instance ${self.id} created'"
}
# Destroy-time provisioner
provisioner "local-exec" {
when = destroy
command = "echo 'Instance ${self.id} destroyed'"
}
}
The destroy-time provisioner might be used for cleanup tasks or to update inventory files when a resource is removed.
Handling Provisioner Failures
By default, if a provisioner fails, Terraform will mark the resource as "tainted." This means Terraform will plan to destroy and recreate the resource during the next apply.
You can change this behavior using the on_failure
parameter:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
provisioner "remote-exec" {
inline = [
"sudo yum update -y",
"sudo yum install -y httpd"
]
on_failure = continue
}
}
The on_failure
parameter can be set to:
continue
: Ignore the failure and continue with the applyfail
(default): Mark the resource as tainted and fail the apply
Best Practices for Using Provisioners
While provisioners can be powerful, they should be used judiciously:
-
Use provisioners as a last resort: Prefer using built-in resource functionality whenever possible.
-
Consider alternatives: For configuration management, consider dedicated tools like Ansible, Chef, or Puppet.
-
Utilize
null_resource
: For provisioners that don't relate to a specific resource, usenull_resource
.
resource "null_resource" "example" {
provisioner "local-exec" {
command = "echo 'This runs whenever the null_resource changes'"
}
triggers = {
# This causes the provisioner to run when the value changes
instance_ids = join(",", aws_instance.example.*.id)
}
}
-
Keep provisioners simple: Complex logic should be moved to scripts that are called by the provisioner.
-
Use
depends_on
for dependencies: Ensure resources are created in the correct order.
resource "aws_instance" "app" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
depends_on = [aws_instance.database]
}
Real-World Example: Configuring a Web Server Cluster
Let's put everything together in a more complex example:
# Create a VPC (configuration omitted for brevity)
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
# other configuration...
}
# Create a security group for web servers
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow web traffic"
vpc_id = aws_vpc.main.id
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 = ["0.0.0.0/0"] # In production, restrict this to your IP
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Create EC2 instances
resource "aws_instance" "web" {
count = 2
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
subnet_id = aws_subnet.main[count.index % 2].id
key_name = "my-key-pair"
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "web-server-${count.index}"
}
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/my-key-pair.pem")
host = self.public_ip
}
# Copy configuration files
provisioner "file" {
source = "files/web-config/"
destination = "/tmp/web-config"
}
# Set up the web server
provisioner "remote-exec" {
inline = [
"sudo yum update -y",
"sudo yum install -y httpd",
"sudo cp -R /tmp/web-config/* /var/www/html/",
"sudo systemctl start httpd",
"sudo systemctl enable httpd",
"echo 'Server ${count.index} is up' | sudo tee /var/www/html/server-info.html"
]
}
}
# Create a load balancer (configuration omitted for brevity)
resource "aws_lb" "web" {
name = "web-lb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.web.id]
subnets = aws_subnet.main.*.id
}
# Update local inventory
resource "null_resource" "inventory" {
provisioner "local-exec" {
command = <<-EOT
echo "[webservers]" > inventory.ini
%{for instance in aws_instance.web~}
echo "${instance.public_ip} ansible_user=ec2-user" >> inventory.ini
%{endfor~}
EOT
}
triggers = {
instance_ids = join(",", aws_instance.web.*.id)
}
}
This example:
- Creates a VPC and security group
- Launches two EC2 instances
- Uses the
file
provisioner to copy web configuration files - Uses the
remote-exec
provisioner to set up the web servers - Creates a load balancer
- Uses a
null_resource
with alocal-exec
provisioner to generate an inventory file for Ansible
Visualizing Provisioner Execution Flow
Let's visualize the execution flow of provisioners:
Limitations and Considerations
While provisioners are useful, they have some limitations:
-
Non-declarative: Provisioners are imperative and don't match Terraform's declarative approach.
-
State management: Terraform cannot track the state of changes made by provisioners.
-
Connection issues: Network problems can cause provisioners to fail unpredictably.
-
Security concerns: Storing credentials for remote execution can pose security risks.
-
Maintenance overhead: Provisioner scripts need to be maintained separately from your infrastructure code.
Summary
Terraform provisioners bridge the gap between infrastructure provisioning and configuration management. They allow you to:
- Execute commands on local or remote machines
- Copy files to remote resources
- Perform actions when resources are created or destroyed
While powerful, provisioners should be used thoughtfully and as a last resort. For complex configuration management tasks, consider dedicated tools like Ansible, Chef, or Puppet.
Remember the key types of provisioners:
local-exec
: Runs commands on the Terraform machineremote-exec
: Runs commands on remote resourcesfile
: Copies files to remote resources
By following best practices and understanding their limitations, you can effectively use provisioners to extend Terraform's capabilities.
Additional Resources
- Terraform Provisioners Documentation
- Terraform Best Practices
- Alternative Configuration Management Tools
Exercises
-
Create a Terraform configuration that provisions an EC2 instance and uses a
local-exec
provisioner to store its IP address in a file. -
Extend the previous exercise to use a
remote-exec
provisioner to install and configure Nginx on the EC2 instance. -
Create a configuration that uses a
file
provisioner to copy a custom HTML template to the EC2 instance, and aremote-exec
provisioner to customize it with instance-specific details. -
Implement a destroy-time provisioner that backs up configuration files before destroying an instance.
-
Create a complex configuration that uses
null_resource
and provisioners to coordinate actions across multiple resources.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)