Terraform: configure servers on first boot, Windows and Linux
Terraform builds the machine; something has to configure it. How to make every instance join configuration management, get its baseline in seconds, and leave the fleet on destroy, with the Gardience provider.
The common pattern is Terraform to provision and Ansible (or Salt, Chef, Puppet) to configure. It works, but the handoff is the weak spot: a provisioner block that SSHes in, a pipeline step that runs a playbook after apply, an inventory that has to know about new machines, and nothing that cleans up when a machine is destroyed.
A cleaner handoff: the machine enrolls itself on first boot, and the configuration system reacts to its arrival.
The idea#
- Terraform creates an enrollment in the configuration system and gets a first-boot script.
- The script goes in the instance's
user_data(cloud-init on Linux, PowerShell on Windows). - On first boot the machine installs the management agent, joins, and lands in its groups.
- A join rule applies the machine's state the moment it arrives. No inventory, no SSH from the pipeline.
- On
terraform destroy, the machine is removed from the fleet.
With the Gardience provider#
Gardience is a supported console on open-source Salt for Windows and Linux, and its provider for Terraform and OpenTofu works this way:
terraform {
required_providers {
gardience = { source = "gardience/gardience" }
}
}
provider "gardience" {} # GARDIENCE_URL and GARDIENCE_API_KEY
resource "gardience_enrollment" "web" {
label = "web tier"
hours = 2160 # open for 90 days
groups = ["web"]
}
resource "gardience_state" "web_base" {
name = "web_base"
content = <<-YAML
nginx:
pkg.installed: []
service.running:
- enable: True
YAML
}
resource "gardience_event_rule" "web_join" {
id = "web-join"
kind = "join"
scope = "group"
group = "web"
state = gardience_state.web_base.name
}
data "gardience_bootstrap_script" "web1" {
enrollment_id = gardience_enrollment.web.id
os = "linux"
node = "web-1"
}
resource "aws_instance" "web1" {
ami = var.ami
instance_type = "t3.small"
user_data = data.gardience_bootstrap_script.web1.content
}
resource "gardience_machine" "web1" {
name = "web-1"
depends_on = [aws_instance.web1]
}What happens on terraform apply:
- The instance boots and the script installs the pinned, hash-checked Salt agent, then joins without anyone approving it (the enrollment's token is the approval).
- The machine lands in the
webgroup and the join rule appliesweb_basewithin seconds. gardience_machinewaits until the machine has joined, so later resources can rely on it.- From then on it is patched, scanned against a STIG/CIS-aligned baseline, watched for drift, and audited like every other machine.
On terraform destroy, gardience_machine removes the machine from the fleet, so dead machines never pile up.
For Windows, use os = "windows" and wrap the script for your platform; on AWS: user_data = "<powershell>${data.gardience_bootstrap_script.win1.content}</powershell>".
Why this beats a provisioner block#
| Provisioner or pipeline step | First-boot enrollment plus join rule | |
|---|---|---|
| Network | Pipeline needs SSH or WinRM into the new machine | Machine dials out; nothing inbound |
| Credentials | Pipeline holds host credentials | Pipeline holds one scoped API key |
| Autoscaling groups | Pipeline does not see new instances | Every instance enrolls itself |
| Cleanup | Manual | Removed on destroy |
| Audit | Pipeline logs | Every change in a tamper-evident audit log |
Guardrails worth knowing#
- The provider uses a configure API key: it manages enrollments, groups, saved states, and event rules, and removes machines, but never runs commands on the fleet. Only admins can create one.
- Saved states are checked by Gardience before publishing; while command execution is off, states that run commands are refused.
- Join rules fire once per machine, the first time it connects, and skip locked machines.
Questions#
Does this work with OpenTofu?#
Yes. The provider supports both Terraform and OpenTofu.
Can I keep using Ansible playbooks for configuration?#
Yes. Gardience runs existing Ansible playbooks over the Salt connection, on Windows and Linux, without SSH keys or WinRM.
What about autoscaling groups?#
Put the enrollment's generic script (linux_script or windows_script) in the launch template. Every instance the group creates enrolls itself and gets its state from the join rule.
Try Gardience on your own machines
One supported console for Windows and Linux on open-source Salt: patching, a scored STIG/CIS-aligned baseline, drift repair, event-driven automation, two-person approval and a tamper-evident audit log. Flat yearly pricing, not per node.
Free 90-day trial (25 nodes)See it work Compare