# terraform / provisioners / bootstrapping

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.

user_data = <<-EOFRECOMMENDED
bootstrapcloud-init (Native OS)
networkNo Inbound SSH Required
provisionerLast Resort Fallback
1
IaC Tool
Ansible
Config Handoff
0
SSH Fragility
# separation of concerns

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 / ApproachPrimary JobBest Used For
TerraformInfrastructure as CodeProvisioning servers, networks, DNS, storage
cloud-init (user_data)Server BootstrapInstalling Docker, basic updates on first boot
AnsibleConfiguration ManagementFleet maintenance, rolling updates, security patches
PackerGolden AMI BakingPre-installing all heavy dependencies into an image
philosophy

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.

# recommended pattern

1. Cloud-Init user_data (The Production Standard)

ec2_bootstrap.tfUSER_DATA SCRIPT
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
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"
  }
}
why it wins

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.
# local-exec

2. provisioner "local-exec": Running Local Commands

main.tfLOCAL-EXEC TRIGGER
1 2 3 4 5 6 7 8 9 10 11 12 13
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"
  }
}
keyword

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).

# remote-exec

3. provisioner "remote-exec": Direct SSH Execution

remote.tfREMOTE SSH EXECUTION
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
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"
    ]
  }
}
failure mode

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.

# modern replacement

4. The terraform_data Resource (Modern null_resource)

hooks.tfTERRAFORM_DATA & TRIGGERS
1 2 3 4 5 6 7 8 9 10 11
# 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!\"}'"
  }
}
built-in

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.

# architectural verdict

Why Provisioners are a Last Resort

Provisioner LimitationRecommended 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.