Article · part of a guide
Terraform Remote-Exec: A Concise Guide
Master Terraform’s remote-exec provisioner: clear setup steps, SSH tips, examples, and best-practice alternatives for post-provision automation.

Key takeaways
- The remote-exec provisioner runs commands directly on a newly created remote resource, but it is not declarative, not tracked in state, and not idempotent by default.
- Every remote-exec provisioner needs a connection block, using SSH for Linux or WinRM for Windows, and supports inline commands, a single script, or multiple scripts.
- HashiCorp treats provisioners as a last resort, so cloud-init, user data, configuration management tools, or provider features like AWS Systems Manager are often better choices.
- Securing remote-exec means using sensitive variables or external secret stores for credentials, restricting network access via security groups, and writing idempotent scripts.
- At enterprise scale, remote-exec faces connection limits, network reliability, and audit challenges that centralized platforms like Scalr help address.
This article is part of our Terraform Provisioners guide.
What Does the Remote-Exec Provisioner Do?
The remote-exec provisioner runs commands on a resource right after Terraform creates it, by opening an SSH or WinRM connection to the machine. People reach for it to install software or finish setup that the base image doesn't handle. HashiCorp calls provisioners a last resort, and they're right to, but a few jobs still need one.
This guide goes through the syntax, how connections work for Linux and Windows, a few real examples, and the security side of it. It also covers what tends to break when you run remote-exec across a lot of machines, and the declarative options worth trying before you reach for it.
How Does Remote-Exec Differ from Local-Exec?
The remote-exec provisioner runs scripts or commands on a remote resource after it's been created. local-exec runs commands on the machine running Terraform; remote-exec connects to the target resource and runs them there.
When to Use Remote-Exec
Appropriate use cases:
- Simple post-deployment configuration tasks
- Software installation on newly created servers
- Service initialization and bootstrapping
- Testing and validation of deployed resources
- One-time setup operations that can't be handled by cloud-init
When to avoid remote-exec:
- Complex configuration management (use specialized tools)
- Ongoing maintenance tasks (not tracked in Terraform state)
- When cloud-init or user data scripts are sufficient
- For changes that should be detected by future Terraform runs
The key limitation is that provisioners aren't declarative, aren't tracked in state, can't detect drift, and aren't idempotent by default.
How Do You Configure a Remote-Exec Provisioner?
Connection Block Structure
Every remote-exec provisioner needs a connection block that tells Terraform how to reach the remote resource:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("${path.module}/private_key.pem")
host = self.public_ip
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
"sudo systemctl start nginx",
"sudo systemctl enable nginx"
]
}
}Execution Methods
Remote-exec supports three execution methods:
1. Inline commands:
provisioner "remote-exec" {
inline = [
"command1",
"command2",
"command3"
]
}2. Single script file:
provisioner "remote-exec" {
script = "path/to/setup.sh"
}3. Multiple script files:
provisioner "remote-exec" {
scripts = [
"path/to/first_script.sh",
"path/to/second_script.sh"
]
}Failure Handling
Control how Terraform handles provisioner failures:
provisioner "remote-exec" {
inline = [
"command1",
"command2"
]
on_failure = "continue" # or "fail" (default)
when = "create" # or "destroy"
}Should You Connect Over SSH or WinRM?
SSH for Linux/Unix Systems
Basic SSH configuration:
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
port = 22
timeout = "2m"
}SSH with bastion host:
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.private_ip
bastion_host = "bastion.example.com"
bastion_user = "bastion-user"
bastion_private_key = file("~/.ssh/bastion_key")
}WinRM for Windows Systems
Basic WinRM configuration:
connection {
type = "winrm"
user = "Administrator"
password = var.admin_password
host = self.public_ip
port = 5985
timeout = "10m"
}Windows instance with WinRM setup:
resource "aws_instance" "windows" {
ami = "ami-windows-server-2019"
instance_type = "t2.medium"
user_data = <<EOF
<powershell>
Enable-PSRemoting -Force
winrm quickconfig -q
winrm set winrm/config/service '@{AllowUnencrypted="true"}'
winrm set winrm/config/service/auth '@{Basic="true"}'
netsh advfirewall firewall add rule name="WinRM-HTTP" dir=in localport=5985 protocol=TCP action=allow
</powershell>
EOF
connection {
type = "winrm"
user = "Administrator"
password = var.admin_password
host = self.public_ip
port = 5985
timeout = "10m"
}
provisioner "remote-exec" {
inline = [
"powershell.exe -Command \"Install-WindowsFeature -Name Web-Server -IncludeManagementTools\"",
"powershell.exe -Command \"Add-Content -Path C:\\inetpub\\wwwroot\\index.html -Value '<h1>Hello from Terraform!</h1>'\""
]
}
}What Do Real Remote-Exec Deployments Look Like?
Application Deployment
resource "aws_instance" "app_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
# Copy application files
provisioner "file" {
source = "app/"
destination = "/tmp/app"
}
# Install dependencies and deploy
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nodejs npm nginx",
"cd /tmp/app && npm install",
"sudo cp -r /tmp/app /var/www/",
"sudo chown -R www-data:www-data /var/www/app",
"sudo systemctl start nginx",
"sudo systemctl enable nginx"
]
}
}Database Configuration
resource "aws_instance" "database" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.small"
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y postgresql postgresql-contrib",
"sudo systemctl start postgresql",
"sudo systemctl enable postgresql",
"sudo -u postgres createdb ${var.database_name}",
"sudo -u postgres psql -c \"CREATE USER ${var.db_user} WITH PASSWORD '${var.db_password}';\"",
"sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE ${var.database_name} TO ${var.db_user};\""
]
}
}Cluster Node Configuration
resource "aws_instance" "k8s_worker" {
count = var.worker_count
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.medium"
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -",
"echo 'deb https://apt.kubernetes.io/ kubernetes-xenial main' | sudo tee /etc/apt/sources.list.d/kubernetes.list",
"sudo apt-get update",
"sudo apt-get install -y kubelet kubeadm kubectl",
"sudo kubeadm join ${var.master_ip}:6443 --token ${var.join_token} --discovery-token-ca-cert-hash ${var.cert_hash}"
]
}
depends_on = [aws_instance.k8s_master]
}How Do You Secure Remote-Exec Provisioners?
Credential Management
Use variables for sensitive data:
variable "admin_password" {
type = string
sensitive = true
description = "Administrator password for Windows instances"
}
connection {
type = "winrm"
user = "Administrator"
password = var.admin_password
host = self.public_ip
}Use external secret management:
data "aws_secretsmanager_secret_version" "db_creds" {
secret_id = "prod/database/credentials"
}
locals {
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db_creds.secret_string)
}
provisioner "remote-exec" {
inline = [
"export DB_USER='${local.db_creds.username}'",
"export DB_PASS='${local.db_creds.password}'",
"bash /tmp/configure_db.sh"
]
}Network Security
Restrict access with security groups:
resource "aws_security_group" "provisioning" {
name = "terraform-provisioning"
description = "Temporary access for Terraform provisioning"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["${data.external.my_ip.result.ip}/32"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "terraform-provisioning"
}
}Script Security
Make scripts idempotent and secure:
provisioner "remote-exec" {
inline = [
"set -euo pipefail", # Exit on error, undefined vars, pipe failures
"export DEBIAN_FRONTEND=noninteractive",
"if ! command -v nginx &> /dev/null; then",
" sudo apt-get update",
" sudo apt-get install -y nginx",
"fi",
"sudo systemctl enable nginx",
"sudo systemctl start nginx"
]
}Why Is Remote-Exec Failing on Your Instances?
Connection Problems
SSH timeout errors:
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
timeout = "10m" # Increase timeout
agent = false # Disable SSH agent
}Host key verification:
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
host_key = null # Skip host key verification (use carefully)
}Script Execution Issues
Handle missing dependencies:
provisioner "remote-exec" {
inline = [
"which curl || sudo apt-get install -y curl",
"which jq || sudo apt-get install -y jq",
"curl -s https://api.example.com/status | jq '.health'"
]
}Wait for system readiness:
provisioner "remote-exec" {
inline = [
"until [ -f /var/lib/cloud/instance/boot-finished ]; do sleep 1; done",
"sudo apt-get update",
"sudo apt-get install -y nginx"
]
}Which Alternatives to Remote-Exec Should You Try First?
Cloud-Init for Initial Configuration
Instead of remote-exec for basic setup:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
echo "<h1>Hello World</h1>" > /var/www/html/index.html
EOF
}Configuration Management Tools
Ansible integration:
resource "null_resource" "ansible_playbook" {
provisioner "local-exec" {
command = "ansible-playbook -i '${aws_instance.web.public_ip},' --private-key=${var.private_key_path} playbook.yml"
}
depends_on = [aws_instance.web]
}Provider-Specific Solutions
AWS Systems Manager:
resource "aws_ssm_document" "install_nginx" {
name = "install-nginx"
document_type = "Command"
content = jsonencode({
schemaVersion = "2.2"
description = "Install nginx"
mainSteps = [{
action = "aws:runShellScript"
name = "installNginx"
inputs = {
runCommand = [
"apt-get update",
"apt-get install -y nginx",
"systemctl start nginx"
]
}
}]
})
}
resource "aws_ssm_association" "install_nginx" {
name = aws_ssm_document.install_nginx.name
targets {
key = "InstanceIds"
values = [aws_instance.web.id]
}
}What Breaks When You Run Remote-Exec at Scale?
Challenges with Large Deployments
Once you're running remote-exec across a lot of infrastructure, a few problems show up:
- Concurrent connection limits: most systems limit simultaneous SSH/WinRM connections
- Network reliability: provisioners can fail due to transient network issues
- Credential management: securely distributing and rotating access credentials
- Audit and compliance: tracking what commands were executed where and when
- State management: provisioners don't integrate with Terraform state for drift detection
Enterprise Solutions
Infrastructure management platforms deal with these by giving you:
- Centralized execution: commands are executed from a secure, managed environment
- Credential injection: secrets are securely injected at runtime without storage in configuration
- Execution logging: all provisioner activities are logged for audit purposes
- Policy enforcement: governance policies can prevent risky provisioner configurations
- Scalable orchestration: handle hundreds of concurrent provisioning operations
For organizations managing complex Terraform deployments, platforms like Scalr provide these capabilities while maintaining the flexibility of remote-exec when needed.
What's a Quick Reference for Remote-Exec Best Practices?
| Aspect | Recommendation | Notes |
|---|---|---|
| Use Cases | Last resort for simple, one-time configuration | Prefer cloud-init, user data, or configuration management tools |
| Connection Type | SSH for Linux, WinRM for Windows | Use key-based authentication over passwords |
| Script Organization | Use external scripts for complex logic | Inline commands for simple, short operations |
| Error Handling | Always include set -e in bash scripts |
Use on_failure = "continue" judiciously |
| Security | Store credentials in external secret management | Never hardcode passwords or keys |
| Network Access | Restrict to specific IPs via security groups | Use bastion hosts for private resources |
| Timeout Settings | Set realistic timeouts (5-10 minutes) | Consider long-running operations carefully |
| Idempotency | Make scripts idempotent with proper checks | Avoid destructive operations without guards |
| Scalability | Use orchestration platforms for large deployments | Consider centralized execution for enterprise use |
| Alternatives | Evaluate cloud-init, SSM, or config management first | Reserve remote-exec for specific requirements |
What to Remember
Remote-exec gives you flexibility, but it lacks state management and drift detection. Always use secure authentication and restrict network access, and write scripts so they're safe to run multiple times. Before reaching for it, check whether cloud-init, user data, or configuration management tools fit the job. At enterprise scale, deployments benefit from centralized management platforms.
remote-exec is fine for the occasional one-off, as long as the connection is secure and the script can run twice without causing problems. The trouble starts when you start depending on it. Terraform doesn't track what the script does or notice when that drifts, and managing SSH or WinRM connections gets harder the more machines you have. So keep it to the cases that genuinely need it, use a declarative option everywhere else, and once you're running it at any real scale, let a central platform handle execution and keep the audit trail.
About the author

CEO 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.
Part of this guide
5 sheets