
This article is part of our Terraform Provisioners guide.
Terraform provisioners run scripts on a resource after it's created. The connection block is what tells Terraform how to reach that resource in the first place. HashiCorp recommends provisioners only as a last resort, but you still hit cases where nothing else fits, and when you do, the connection is usually where things break. This guide covers how connections work, the parameters they take, and the mistakes that trip people up.
A connection block tells Terraform how to talk to a remote resource when it runs a provisioner. It holds the authentication, network, and protocol details Terraform needs to reach the target. Get the connection wrong and a remote provisioner can't run commands or copy files, so nothing gets configured.
You need a connection because most provisioners (except local-exec) have to reach a remote resource over SSH or WinRM. HashiCorp recommends alternatives like cloud-init or Packer-built images for most cases, but provisioners still earn their keep for bootstrapping, cleanup, or the odd configuration task that doesn't fit Terraform's declarative model.
Provisioners are the imperative escape hatch in an otherwise declarative tool. They run 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"]
}
}Each provisioner type uses the connection differently, and one of them skips it entirely:
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"
}
}Provisioner connections are how you configure a resource after it's deployed when nothing else will do the job. HashiCorp recommends using provisioners sparingly, and that advice holds up. But when you do need one, knowing the connection types, the parameters, and the failure modes is what keeps an apply from stalling on a timeout you can't explain.
SSH covers Linux and Unix, WinRM covers Windows, and each supports a few authentication methods. Most of the trouble comes down to a handful of repeat offenders: a missing connection block, a direct resource reference that creates a dependency cycle, or a security group that never opened the right port.
For most setups, cloud-init, a Packer-built image, or a tool like Ansible will be easier to live with than a provisioner. Keep provisioners for the cases where those alternatives genuinely can't reach.
