Terraform & Infrastructure as Code›05 · Provisioners & bootstrap
Learning Hub / Delivery & Infrastructure as Code / Terraform & Infrastructure as Code

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.

Practitioner
Key wordsprovisionerslocal-execremote-execcloud-inituser_datatemplatefileterraform_dataimages vs bootstrap

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:

  1. Pre-built images: bake the OS, agents and hardening in advance (Packer or an image pipeline).
  2. cloud-init (user data): small, first-boot customisation (hostname, keys, joining a cluster).
  3. Configuration management: Ansible and similar tools for long-lived hosts that change over time.
  4. 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-exec calling an idempotent script over remote-exec SSH sessions.
  • Use terraform_data (built in since Terraform 1.4) instead of the older null_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)

  1. Write cloud-init.yaml.tftpl as above, and render it with terraform console: templatefile("cloud-init.yaml.tftpl", { hostname = "lab-01", ssh_key = "ssh-ed25519 AAAA…", environment = "lab" }).
  2. 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.
  3. On the VM, check cloud-init status, the MOTD, the ops user and the installed packages.
  4. Add a terraform_data with a local-exec that appends the VM's name to inventory.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.