
This article is part of our Terraform Provisioners guide.
Terraform provisioners enable post-deployment configuration by executing scripts on resources after creation. Their connections determine how Terraform communicates with these resources. Despite HashiCorp recommending provisioners only as a last resort, understanding their connection mechanisms is essential for situations where alternatives won't suffice.
Provisioner connections are configuration blocks that specify how Terraform communicates with remote resources when executing provisioners. They define the authentication, network, and protocol details needed to establish secure connections to target resources. Without properly configured connections, remote provisioners cannot execute commands or transfer files to configure your infrastructure.
Connections are necessary because most provisioners (except local-exec) need to interact with remote resources through protocols like SSH or WinRM. While HashiCorp recommends alternatives like cloud-init or Packer-built images for most scenarios, provisioners remain valuable for bootstrapping, cleanup, or specialized configuration tasks that fall outside Terraform's declarative model.
Provisioners in Terraform serve as bridges between declarative infrastructure definition and imperative configuration management. They execute at specific points in the resource lifecycle:
Creation-time provisioners run after a resource is created but not during updates. If they fail, the resource is marked as "tainted" and recreated on the next apply. These are used for bootstrapping activities.
Destroy-time provisioners (specified with when = destroy) execute before resource destruction. They handle cleanup tasks but won't run if the resource is tainted or if they're removed from configuration when the resource is destroyed.
Provisioners have a parent-child relationship with resources. They can access parent resource attributes using the self object (e.g., self.public_ip), and Terraform ensures proper execution order relative to resource creation or destruction.
The connection block establishes how Terraform communicates with the resource:
resource "aws_instance" "example" {
# Resource configuration...
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
provisioner "remote-exec" {
inline = ["echo 'Hello, World!'"]
}
}Terraform supports two primary connection types for provisioners: SSH for Linux/Unix systems and WinRM for Windows systems.
SSH connections are the most common and support multiple authentication methods. The basic configuration includes:
connection {
type = "ssh"
user = "ec2-user"
host = self.public_ip
private_key = file("~/.ssh/id_rsa")
}Core SSH parameters:
| Parameter | Description | Default |
|---|---|---|
| host | Target IP address or hostname (required) | - |
| user | Username for authentication | "root" |
| password | Password for authentication | - |
| private_key | SSH private key content | - |
| port | SSH port | 22 |
| timeout | Connection timeout | "5m" |
| agent | Whether to use SSH agent | true |
| agent_identity | Specific key to use with agent | - |
Advanced SSH parameters include support for certificate authentication, bastion hosts, and proxies:
connection {
type = "ssh"
user = "ec2-user"
host = self.private_ip
private_key = file("~/.ssh/id_rsa")
# Bastion host configuration
bastion_host = aws_instance.bastion.public_ip
bastion_user = "ec2-user"
bastion_private_key = file("~/.ssh/bastion_key")
# Proxy configuration
proxy_scheme = "socks5"
proxy_host = "proxy.example.com"
proxy_port = 1080
}WinRM connections are used for Windows systems and support HTTP or HTTPS protocols:
connection {
type = "winrm"
user = "Administrator"
password = var.admin_password
host = self.public_ip
port = 5986
https = true
insecure = true # Disables certificate validation
}Core WinRM parameters:
| Parameter | Description | Default |
|---|---|---|
| host | Target IP address or hostname (required) | - |
| user | Username for authentication | "Administrator" |
| password | Password for authentication | - |
| port | WinRM port | 5985 (HTTP), 5986 (HTTPS) |
| https | Whether to use HTTPS | false |
| insecure | Skip certificate validation | false |
| use_ntlm | Use NTLM authentication | false |
| timeout | Connection timeout | "5m" |
Connections can be specified at two levels, with provisioner-level settings taking precedence:
resource "aws_instance" "example" {
# Resource configuration...
# Resource-level connection (default for all provisioners)
connection {
type = "ssh"
user = "ec2-user"
host = self.public_ip
}
# Uses resource-level connection
provisioner "file" {
source = "local/file.txt"
destination = "/tmp/file.txt"
}
# Overrides resource-level connection
provisioner "remote-exec" {
connection {
type = "ssh"
user = "admin" # Different user
password = var.admin_password
host = self.public_ip
}
inline = ["systemctl restart nginx"]
}
}Different provisioner types interact with connections in unique ways:
The file provisioner transfers files from the local machine to the remote resource:
provisioner "file" {
source = "local/path/file.txt"
destination = "/remote/path/file.txt"
}With SSH connections, it uses SCP (Secure Copy Protocol) for file transfers, requiring the scp service on the remote host. The destination directory must already exist.
With WinRM connections, it transfers files by encoding them in base64 and reconstructing them on the target system. Windows paths should use forward slashes to avoid escape character issues (e.g., C:/Windows/Temp).
The remote-exec provisioner executes commands on the remote resource:
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
"sudo systemctl start nginx"
]
}With SSH connections, scripts are uploaded via SCP and executed using the default shell (bash/sh). The default script path is /tmp/terraform_<random>.sh.
With WinRM connections, commands are executed using PowerShell or CMD. The default script path is C:\Windows\Temp\terraform_<random>.cmd.
The local-exec provisioner is unique as it runs commands on the local machine executing Terraform, so it doesn't require a connection block:
provisioner "local-exec" {
command = "echo 'Local execution completed'"
}HashiCorp officially recommends using provisioners only as a last resort, preferring other approaches for configuration management. When provisioners are necessary, follow these best practices:
Never hardcode sensitive credentials in your Terraform configuration:
# BAD - hardcoded credentials
connection {
password = "insecure_password" # Don't do this!
}
# GOOD - use variables marked as sensitive
variable "admin_password" {
type = string
sensitive = true
}
connection {
password = var.admin_password
}Use ephemeral values for sensitive data (Terraform v1.10+) to prevent credentials from being stored in state files.
Implement least privilege principles by using more limited user accounts when possible:
# First connect as root to set up a user
provisioner "remote-exec" {
connection {
user = "root"
# root connection details
}
inline = [
"useradd -m appuser",
"mkdir -p /home/appuser/.ssh",
"echo '${file("~/.ssh/app_key.pub")}' > /home/appuser/.ssh/authorized_keys",
"chmod 700 /home/appuser/.ssh",
"chmod 600 /home/appuser/.ssh/authorized_keys",
"chown -R appuser:appuser /home/appuser/.ssh"
]
}
# Then connect as the new user with limited permissions
provisioner "remote-exec" {
connection {
user = "appuser"
private_key = file("~/.ssh/app_key")
# other connection details
}
inline = [
"# Commands that don't need root access"
]
}Enable SSH host key verification in security-critical environments by setting the host_key parameter.
Set appropriate timeouts for your environment to avoid unnecessary delays:
connection {
timeout = "10m" # Increase from default 5m for slow networks
}Optimize script execution by combining commands to reduce connection overhead:
provisioner "remote-exec" {
inline = [
"apt-get update && apt-get install -y nginx docker.io",
"systemctl enable nginx docker && systemctl start nginx docker"
]
}Be aware of resource contention when running multiple provisioners in parallel.
Control failure behavior using the on_failure parameter:
provisioner "remote-exec" {
inline = [
"some-command-that-might-fail"
]
on_failure = continue # Instead of failing and tainting
}Use the self_propagation setting (for destroy-time provisioners) to control whether they run when the configuration is removed.
Missing connection block results in "Missing connection configuration for provisioner" error. The file and remote-exec provisioners require connection blocks.
Improper resource references in connection blocks cause dependency cycle errors:
# WRONG - creates a dependency cycle
connection {
host = aws_instance.example.public_ip # Don't do this!
}
# CORRECT - use self reference
connection {
host = self.public_ip
}Network and security group issues often prevent connections:
Authentication problems occur with:
Path issues on Windows happen with backslashes:
# WRONG - backslashes cause escaping issues
destination = "C:\\Program Files\\file.txt"
# CORRECT - use forward slashes
destination = "C:/Program Files/file.txt"Enable detailed logging to see what's happening:
export TF_LOG=DEBUG
export TF_LOG_PATH=terraform.log
terraform applyTest connections manually before running Terraform:
# Test SSH connection
ssh -i path/to/key.pem user@host
# Test WinRM connection (using PowerShell)
$password = ConvertTo-SecureString 'YourPassword' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential ('Administrator', $password)
Enter-PSSession -ComputerName host -Credential $cred -UseSSLAdd debug output to scripts for better visibility:
provisioner "remote-exec" {
inline = [
"set -x", # Enable command tracing in bash
"echo 'Starting configuration...'",
"command1 > /tmp/output.log 2>&1",
"echo 'Exit code: $?'"
]
}Use null_resource for isolated testing of connection issues:
resource "null_resource" "connection_test" {
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/key.pem")
host = "11.22.33.44" # Explicitly set IP for testing
}
provisioner "remote-exec" {
inline = ["echo 'Connection successful'"]
}
}Bootstrap a newly created server with basic software and configuration:
resource "aws_instance" "web" {
ami = "ami-12345678"
instance_type = "t2.micro"
key_name = "mykey"
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/mykey.pem")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo yum update -y",
"sudo yum install -y nginx",
"sudo systemctl start nginx"
]
}
}Connect through a jumpbox to reach instances in private subnets:
resource "aws_instance" "private_instance" {
# instance configuration...
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/private_key.pem")
host = self.private_ip
bastion_host = aws_instance.bastion.public_ip
bastion_user = "ec2-user"
bastion_private_key = file("~/.ssh/bastion_key.pem")
}
provisioner "remote-exec" {
inline = [
"echo 'Connected through bastion host'"
]
}
}Setting up distributed systems and joining nodes to a cluster:
resource "aws_instance" "leader" {
# instance configuration...
provisioner "remote-exec" {
connection {
# connection details...
}
inline = [
"consul agent -server -bootstrap-expect=3 -node=consul-server-1 -bind=$(hostname -I | awk '{print $1}') -data-dir=/tmp/consul"
]
}
}
resource "aws_instance" "follower" {
# instance configuration...
count = 2
provisioner "remote-exec" {
connection {
# connection details...
}
inline = [
"consul agent -server -node=consul-server-${count.index + 2} -bind=$(hostname -I | awk '{print $1}') -data-dir=/tmp/consul",
"consul join ${aws_instance.leader.private_ip}"
]
}
}Transfer and execute configuration files or scripts:
resource "aws_instance" "app" {
# instance configuration...
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/key.pem")
host = self.public_ip
}
provisioner "file" {
source = "scripts/setup.sh"
destination = "/tmp/setup.sh"
}
provisioner "remote-exec" {
inline = [
"chmod +x /tmp/setup.sh",
"/tmp/setup.sh"
]
}
}HashiCorp recommends these alternatives to provisioners when possible:
Use cloud provider's built-in initialization mechanisms:
resource "aws_instance" "example" {
ami = "ami-12345678"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
EOF
}For more complex configurations, use the cloudinit_config data source.
Build pre-configured images with HashiCorp Packer:
# Using a pre-configured AMI built with Packer
resource "aws_instance" "web" {
ami = "ami-packer-built-image"
instance_type = "t2.micro"
# No provisioners needed as configuration is baked into the AMI
}Use dedicated tools like Ansible, Chef, Puppet, or SaltStack:
resource "aws_instance" "web" {
ami = "ami-12345678"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u ec2-user -i '${self.public_ip},' --private-key ~/.ssh/key.pem playbook.yml"
}
}Terraform provisioner connections provide essential mechanisms for configuring resources post-deployment when other options aren't suitable. While HashiCorp recommends using provisioners sparingly, understanding their connection types, configuration parameters, and best practices ensures you can effectively employ them when necessary.
The SSH and WinRM connection types offer flexibility for different operating systems, with various authentication methods and configuration options to meet diverse requirements. By following security best practices, avoiding common pitfalls, and considering alternatives when appropriate, you can maintain Terraform's declarative nature while accommodating necessary imperative configuration tasks.
For most scenarios, cloud-init, custom images built with Packer, or dedicated configuration management tools offer more robust and maintainable solutions. Reserve provisioners for specialized use cases where these alternatives don't meet your needs.
