TrademarkTrademark
Features
Documentation

Terraform provisioner connections: the complete guide

Master Terraform provisioner connections (SSH, WinRM, remote-exec, and file) with step-by-step examples, pitfalls, and best practices in one concise guide.
Sebastian StadilFebruary 20, 2026Updated March 31, 2026
Terraform provisioner connections: the complete guide
Key takeaways
  • Provisioner connection blocks define the authentication, network, and protocol details Terraform needs to reach remote resources, and provisioners other than local-exec require them.
  • Terraform supports two connection types: SSH for Linux and Unix systems (default port 22) and WinRM for Windows systems (ports 5985 for HTTP, 5986 for HTTPS).
  • Connections can be set at the resource level (applying to all provisioners) or the provisioner level, with provisioner-level settings taking precedence.
  • Reference parent attributes with self (for example self.public_ip) instead of the resource's own address, because a direct reference creates a dependency cycle error.
  • HashiCorp recommends provisioners only as a last resort; cloud-init user data, Packer images, and configuration management tools are usually more reliable alternatives.

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.

What are Terraform provisioner connections and why they matter

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.

Fundamentals of provisioner connections in Terraform

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!'"]
  }
}

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

Each provisioner type uses the connection differently, and one of them skips it entirely:

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

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.

About the author
Sebastian StadilCEO at Scalr
Sebastian Stadil is the CEO of Scalr with 15+ years of DevOps experience. He started with AWS in 2004 and advised early Microsoft Azure and Google Cloud.