Lesson 05 of 7 · Modules
Provisioners & bootstrap
How machines get configured after Terraform creates them: cloud-init and user data first, pre-built images for speed, configuration management for ongoing state, and provisioners only as a last resort, and why.
Terraform creates; something else configures
Terraform is great at creating infrastructure (VMs, networks, disks). Configuring what runs inside a machine is a different job. The options, from most to least preferred:
- Pre-built images: bake the OS, agents and hardening in advance (Packer or an image pipeline).
- cloud-init (user data): small, first-boot customisation (hostname, keys, joining a cluster).
- Configuration management: Ansible and similar tools for long-lived hosts that change over time.
- Provisioners: imperative commands from Terraform itself. Last resort.
Buying a bike: the best is getting it fully assembled from the shop (a pre-built image). Next best, it comes with a short card ("attach the pedals, set the seat height") you follow on day one (cloud-init). The worst is someone shouting instructions from across the street while you build it (provisioners): if they stop halfway, you're left with half a bike and no record of what was done.
cloud-init with templatefile
#cloud-config
# cloud-init.yaml.tftpl
hostname: ${hostname}
users:
- name: ops
groups: [sudo]
shell: /bin/bash
ssh_authorized_keys:
- ${ssh_key}
package_update: true
packages: [chrony, qemu-guest-agent]
write_files:
- path: /etc/motd
content: "Managed by Terraform. Environment: ${environment}\n"
runcmd:
- systemctl enable --now qemu-guest-agent
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.small"
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = "app-01"
ssh_key = var.ssh_public_key
environment = var.environment
})
}
The same file works for OpenStack, libvirt (next lesson), VMware and bare-metal provisioning. On the machine, cloud-init status --wait and /var/log/cloud-init-output.log show what happened.
Images for speed and reliability
Anything slow or fragile at boot (large packages, hardening, agents) belongs in an image built in CI and referenced by Terraform (by ID or a data source lookup). Boots become fast and identical, and air-gapped sites don't need package mirrors at boot (see Edge Kubernetes & Zero-Touch Provisioning).
Provisioners: if you really must
resource "terraform_data" "register" {
triggers_replace = [aws_instance.app.id] # re-run when the instance is replaced
provisioner "local-exec" {
command = "./scripts/register-in-cmdb.sh ${aws_instance.app.private_ip}"
}
}
Rules if you use them:
- Prefer
local-execcalling an idempotent script overremote-execSSH sessions. - Use
terraform_data(built in since Terraform 1.4) instead of the oldernull_resource. - Make the script idempotent and safe to re-run. Terraform won't track what it did.
- Check whether a provider already exists for the job (DNS, CMDB, monitoring APIs often have one).
Try it: cloud-init end to end (free, with libvirt or multipass)
- Write
cloud-init.yaml.tftplas above, and render it withterraform console:templatefile("cloud-init.yaml.tftpl", { hostname = "lab-01", ssh_key = "ssh-ed25519 AAAA…", environment = "lab" }). - Boot a VM with it: with Multipass,
multipass launch --name lab-01 --cloud-init rendered.yaml, or with the libvirt provider in the next lesson. - On the VM, check
cloud-init status, the MOTD, theopsuser and the installed packages. - Add a
terraform_datawith alocal-execthat appends the VM's name toinventory.txt, and make it re-run only when the VM is replaced.
Going deeper: bootstrap at scale
- Keep user data small, and remember it's usually visible to anyone who can describe the instance. Never put secrets in it; fetch them at runtime with the machine's identity.
- cloud-init runs once per instance by default. Immutable infrastructure means replacing machines rather than re-running bootstrap.
- Version images and reference them explicitly; roll new images with instance refresh or node pool updates.
Recap
- Order of preference: images → cloud-init → config management → provisioners.
- cloud-init +
templatefile()works across clouds, OpenStack, libvirt and bare metal. - Bake slow/fragile steps into images for fast, identical, offline-friendly boots.
- Provisioners are untracked imperative steps: use
terraform_data+ idempotent scripts, rarely.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.