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:

  1. Resource-level: Applies to all provisioners in that resource
  2. 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.