TerraformPilot

Terraform

Terraform Provisioners - local-exec, remote-exec, and file

Complete guide to Terraform provisioners. local-exec, remote-exec, and file provisioners with SSH connections, error handling, and best practices.

LLuca Berton1 min read

Quick Answer

#
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
 
  provisioner "remote-exec" {
    inline = ["sudo apt update && sudo apt install -y nginx"]
    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }
}

local-exec Provisioner

#

Runs commands on the machine running Terraform (your laptop or CI/CD runner):

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
 
  # Runs after creation
  provisioner "local-exec" {
    command = "echo ${self.public_ip} >> inventory.txt"
  }
 
  # Runs before destruction
  provisioner "local-exec" {
    when    = destroy
    command = "sed -i '/${self.public_ip}/d' inventory.txt"
  }
}

Working Directory and Environment

#
provisioner "local-exec" {
  command     = "./deploy.sh"
  working_dir = "${path.module}/scripts"
  interpreter = ["/bin/bash", "-c"]
  environment = {
    SERVER_IP = self.public_ip
    ENV       = var.environment
  }
}

Windows

#
provisioner "local-exec" {
  command     = "deploy.ps1 -ServerIP ${self.public_ip}"
  interpreter = ["PowerShell", "-Command"]
}

remote-exec Provisioner

#

Runs commands on the remote resource via SSH or WinRM:

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = var.instance_type
  key_name      = aws_key_pair.deploy.key_name
 
  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file(var.private_key_path)
    host        = self.public_ip
    timeout     = "5m"
  }
 
  # Inline commands
  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y docker.io",
      "sudo usermod -aG docker ubuntu",
      "sudo systemctl enable docker",
    ]
  }
 
  # Run a script
  provisioner "remote-exec" {
    script = "${path.module}/scripts/setup.sh"
  }
 
  # Run multiple scripts
  provisioner "remote-exec" {
    scripts = [
      "${path.module}/scripts/install-deps.sh",
      "${path.module}/scripts/configure-app.sh",
    ]
  }
}

Bastion/Jump Host

#
connection {
  type                = "ssh"
  user                = "ubuntu"
  private_key         = file(var.private_key_path)
  host                = self.private_ip
  bastion_host        = aws_instance.bastion.public_ip
  bastion_user        = "ubuntu"
  bastion_private_key = file(var.bastion_key_path)
}

file Provisioner

#

Copies files or directories to the remote resource:

resource "aws_instance" "app" {
  # ... instance config ...
 
  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file(var.private_key_path)
    host        = self.public_ip
  }
 
  # Single file
  provisioner "file" {
    source      = "configs/app.conf"
    destination = "/tmp/app.conf"
  }
 
  # Directory
  provisioner "file" {
    source      = "configs/"
    destination = "/tmp/configs"
  }
 
  # Inline content
  provisioner "file" {
    content     = templatefile("${path.module}/templates/config.tpl", { db_host = var.db_host })
    destination = "/tmp/app-config.json"
  }
 
  provisioner "remote-exec" {
    inline = [
      "sudo mv /tmp/app.conf /etc/app/app.conf",
      "sudo systemctl restart app",
    ]
  }
}

Error Handling

#
provisioner "local-exec" {
  command    = "./deploy.sh"
  on_failure = continue  # Don't fail the resource (default: fail)
}

Best Practices

#
  1. Prefer cloud-init over remote-exec for initial server setup
  2. Use Packer for building AMIs instead of provisioning at deploy time
  3. Provisioners are a last resort — Terraform docs say so explicitly
  4. Use terraform_data instead of null_resource for standalone provisioners
  5. Set timeouts on SSH connections to avoid hanging applies
# Prefer cloud-init
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
 
  user_data = templatefile("${path.module}/cloud-init.yaml", {
    packages = ["nginx", "nodejs"]
  })
}
#

Conclusion

#

Use local-exec for CI/CD integration and local scripts, remote-exec and file for bootstrapping servers that can't use cloud-init, and always prefer immutable infrastructure (Packer AMIs, containers) over runtime provisioning. Provisioners are escape hatches, not primary tools.

#Terraform#DevOps#Infrastructure as Code

Share this article