TrademarkTrademark
Features
Documentation

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.
Sebastian StadilFebruary 20, 2026Updated March 31, 2026
Terraform Remote-Exec: A Concise Guide
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:

  1. Concurrent connection limits: most systems limit simultaneous SSH/WinRM connections
  2. Network reliability: provisioners can fail due to transient network issues
  3. Credential management: securely distributing and rotating access credentials
  4. Audit and compliance: tracking what commands were executed where and when
  5. 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
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.