Bootstrapping & Script Execution: Provisioners & User Data
While Terraform excels at provisioning cloud infrastructure, running post-boot configuration scripts requires care. Learn how to bootstrap servers reliably using cloud-native user_data, when to use local-exec to trigger Ansible, and why Terraform provisioners are treated as a last resort.
Infrastructure Provisioning vs Configuration Management
DevOps tools are specialized. Terraform is designed for Infrastructure Provisioning (creating VPCs, subnets, EC2 instances, S3 buckets). Ansible and Cloud-Init are designed for Configuration Management (installing packages, configuring Nginx, managing users).
| Tool / Approach | Primary Job | Best Used For |
|---|---|---|
| Terraform | Infrastructure as Code | Provisioning servers, networks, DNS, storage |
| cloud-init (user_data) | Server Bootstrap | Installing Docker, basic updates on first boot |
| Ansible | Configuration Management | Fleet maintenance, rolling updates, security patches |
| Packer | Golden AMI Baking | Pre-installing all heavy dependencies into an image |
The Clean Handoff
Terraform creates the virtual machine and security groups. Once the server is online, Terraform hands off to cloud-init or Ansible to configure the application runtime.
1. Cloud-Init user_data (The Production Standard)
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
echo "<h1>Deployed via Terraform with user_data</h1>" > /var/www/html/index.html
systemctl enable --now nginx
EOF
tags = {
Name = "web-server-bootstrapped"
}
}Advantages of user_data
- No Open SSH Ports: Runs locally on the VM hypervisor during initial boot.
- Zero Connection Timeouts: Doesn't require Terraform CLI to maintain an active SSH pipe.
- Immutable Infrastructure: Perfect for autoscaling groups where new instances self-configure automatically.
2. provisioner "local-exec": Running Local Commands
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
# Run a command locally on the laptop / CI runner executing Terraform
provisioner "local-exec" {
command = "echo ${self.public_ip} >> inventory.ini"
}
# Optional: Trigger Ansible playbook after creation
provisioner "local-exec" {
command = "ansible-playbook -i '${self.public_ip},' deploy.yml"
}
}The self Object
Inside a provisioner block, the self keyword references attributes of the parent resource being provisioned (e.g. self.public_ip, self.id).
3. provisioner "remote-exec": Direct SSH Execution
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
key_name = "deployer-key"
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y docker.io",
"sudo systemctl enable --now docker"
]
}
}Provisioner Failure Taints the Resource
If any command in remote-exec returns a non-zero exit code, Terraform marks the entire resource as tainted. On the next apply, Terraform will terminate the server and try again from scratch.
4. The terraform_data Resource (Modern null_resource)
# Triggers arbitrary commands whenever a config file hash changes
resource "terraform_data" "app_config" {
triggers_replace = [
filesha256("${path.module}/config.json")
]
provisioner "local-exec" {
command = "curl -X POST https://api.slack.com/webhook -d '{\"text\":\"Config updated!\"}'"
}
}Zero External Providers Needed
Introduced in Terraform 1.4+, terraform_data is built directly into Terraform core, completely replacing the legacy null_resource from the HashiCorp null provider.
Why Provisioners are a Last Resort
| Provisioner Limitation | Recommended Production Alternative |
|---|---|
| Non-Declarative: Bash scripts are imperative; Terraform cannot plan or preview their changes. | Use user_data / cloud-init for declarative initialization. |
| No State Tracking: Terraform cannot detect if someone modified the software installed by a provisioner. | Use Ansible or SaltStack for continuous state enforcement. |
| Network Sensitivity: Firewalls, SSH rate limits, and flaky connections break applies. | Use Packer to bake all dependencies into custom golden AMIs before Terraform runs. |