Terraform provisioner connections: the complete 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.
What are Terraform provisioner connections and why they matter
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.
Fundamentals of provisioner connections in Terraform
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!'"]
}
}
Connection types and their configuration parameters
Terraform supports two primary connection types for provisioners: SSH for Linux/Unix systems and WinRM for Windows systems.
SSH connection configuration
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 connection configuration
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" |
Connection configuration hierarchy
Connections can be specified at two levels, with provisioner-level settings taking precedence:
- Resource-level: Applies to all provisioners in that resource
- Provisioner-level: Applies only to that specific provisioner
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"]
}
}
Provisioner types and their connection requirements
Different provisioner types interact with connections in unique ways:
file provisioner
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
).
remote-exec provisioner
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
.
local-exec provisioner
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'"
}
Best practices for provisioner connections
HashiCorp officially recommends using provisioners only as a last resort, preferring other approaches for configuration management. When provisioners are necessary, follow these best practices:
Security 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.
Performance considerations
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.
Error handling
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.
Common pitfalls and troubleshooting
Frequent connection errors
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:
- Missing inbound rules for SSH (port 22) or WinRM (ports 5985/5986)
- Unreachable hosts due to VPC/subnet configuration
- Private IPs used when public IPs are needed
Authentication problems occur with:
- Incorrect usernames for the OS (e.g., "ec2-user" for Amazon Linux, "ubuntu" for Ubuntu)
- Wrong key pairs or password
- Permission issues with SSH key files (should be 0600)
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"
Troubleshooting techniques
Enable detailed logging to see what's happening:
export TF_LOG=DEBUG
export TF_LOG_PATH=terraform.log
terraform apply
Test 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 -UseSSL
Add 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'"]
}
}
Real-world use cases and patterns
Initial server configuration
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"
]
}
}
Bastion host pattern
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'"
]
}
}
Cluster configuration
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}"
]
}
}
File upload and execution
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"
]
}
}
Alternatives to provisioners
HashiCorp recommends these alternatives to provisioners when possible:
Cloud-init / user data
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.
Custom machine images (Packer)
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
}
Configuration management tools
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"
}
}
Conclusion
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.