
This article is part of our Terraform Provisioners guide.
Terraform file provisioners copy files from your local machine to newly created resources, bridging the gap between infrastructure provisioning and configuration. While useful for bootstrapping and initial setup, HashiCorp explicitly recommends using them only as a last resort due to their limitations in Terraform's declarative model.
File provisioners address a practical challenge in infrastructure automation: transferring configuration files, scripts, or other assets to newly provisioned resources. They're part of Terraform's broader provisioner concept that HashiCorp describes as "a measure of pragmatism, knowing that there are always certain behaviors that cannot be directly represented in Terraform's declarative model."
The file provisioner transfers files or directories from the local machine running Terraform to newly created resources. It operates alongside two other built-in provisioners:
File provisioners occupy a specific place in Terraform's execution model:
when = destroyThis last point is crucial: provisioner actions are not recorded in state files, meaning Terraform cannot detect or remediate drift in files managed through provisioners.
The file provisioner uses this syntax within a resource block:
resource "aws_instance" "web" {
# Resource configuration...
provisioner "file" {
source = "config/app.conf" # Local file/directory to copy
content = "configuration text" # Alternative to source - direct content
destination = "/etc/app/config.conf" # Remote path to place file/content
# Connection information
connection {
type = "ssh" # SSH or WinRM
user = "ec2-user" # Remote username
private_key = file("key.pem") # Authentication
host = self.public_ip # Remote address
# Other connection options...
}
# Meta-arguments
when = "create" # When to run: "create" (default) or "destroy"
on_failure = "fail" # What to do on failure: "fail" (default) or "continue"
}
}You must specify either source or content (never both), along with a mandatory destination.
You can define connections at the resource level (affecting all provisioners) or inline for each provisioner:
# Resource-level connection (applies to all provisioners)
resource "aws_instance" "web" {
# Resource configuration...
connection {
type = "ssh"
user = "ubuntu"
private_key = file("${path.module}/id_rsa")
host = self.public_ip
}
provisioner "file" { ... }
provisioner "remote-exec" { ... }
}
# Provisioner-specific connection
resource "aws_instance" "web" {
# Resource configuration...
provisioner "file" {
# File provisioner config...
connection {
# Connection details specific to this provisioner
}
}
}When copying directories, behavior depends on trailing slashes:
/local/dir (no trailing slash) to /remote/path → contents copied to /remote/path/dir/local/dir/ (with trailing slash) to /remote/path → contents copied directly into /remote/pathWith SSH connections, the destination directory must already exist. This often requires creating it first:
resource "aws_instance" "web" {
# Resource configuration...
# First create the directory
provisioner "remote-exec" {
inline = ["mkdir -p /opt/application/config"]
}
# Then copy files to it
provisioner "file" {
source = "configs/"
destination = "/opt/application/config"
}
}With WinRM connections, the destination directory is created automatically if it doesn't exist.
HashiCorp's official stance is clear: use provisioners as a last resort. This recommendation stems from several limitations:
Credential management poses significant risks:
Network security issues arise:
File permissions need careful handling:
on_failure = continue when appropriateFile provisioners frequently encounter several types of issues:
Error: timeout - last error: dial tcp x.x.x.x:22: i/o timeout
Common causes include:
Solutions:
depends_on or increase connection.timeout to allow more boot timeError: ssh: handshake failed: ssh: unable to authenticate
Solutions:
Error: Upload failed: scp: /path/to/dir: No such file or directory
Solutions:
remote-exec provisioner first to create the directory~Error: PowerShell exited with code 1
Solutions:
Several approaches offer better alternatives to file provisioners in most scenarios:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
user_data = file("scripts/setup.yaml")
}Best when:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
user_data = templatefile("${path.module}/templates/init.tpl", {
server_name = var.server_name
db_address = aws_db_instance.database.address
})
}Best when:
# Packer builds the image with files included
# Terraform simply references the pre-built image
resource "aws_instance" "web" {
ami = "ami-0dbaca5d269497603" # Pre-built with Packer
instance_type = "t2.micro"
}Best when:
data "cloudinit_config" "config" {
gzip = true
base64_encode = true
part {
content_type = "text/cloud-config"
content = yamlencode({
write_files = [{
path = "/etc/app/config.json"
content = jsonencode(local.app_config)
permissions = "0644"
}]
})
}
}
resource "aws_instance" "web" {
user_data = data.cloudinit_config.config.rendered
}Best when:
This pattern demonstrates using template files to generate configuration dynamically:
data "template_file" "app_config" {
template = file("${path.module}/templates/app_config.json.tpl")
vars = {
db_host = aws_db_instance.database.address
db_port = aws_db_instance.database.port
api_key = var.api_key
environment = var.environment
}
}
resource "aws_instance" "app_server" {
# Instance configuration...
connection {
type = "ssh"
user = "ubuntu"
private_key = file("${path.module}/ssh_key.pem")
host = self.public_ip
}
provisioner "file" {
content = data.template_file.app_config.rendered
destination = "/etc/app/config.json"
}
provisioner "remote-exec" {
inline = ["sudo systemctl restart app-service"]
}
}For updating configurations without rebuilding infrastructure:
resource "null_resource" "deploy_config" {
# Trigger when configuration changes
triggers = {
config_contents = data.template_file.service_config.rendered
}
connection {
type = "ssh"
user = "ubuntu"
private_key = file("${path.module}/ssh_key.pem")
host = aws_instance.web_server.public_ip
}
provisioner "file" {
content = data.template_file.service_config.rendered
destination = "/etc/nginx/sites-available/default"
}
provisioner "remote-exec" {
inline = [
"sudo nginx -t",
"sudo systemctl reload nginx"
]
}
}For more sophisticated deployments:
resource "aws_instance" "application_server" {
# Instance configuration...
# Stage 1: Set up directories
provisioner "remote-exec" {
inline = [
"mkdir -p /opt/app/config",
"mkdir -p /opt/app/logs",
"mkdir -p /opt/app/data"
]
}
# Stage 2: Deploy configuration files
provisioner "file" {
source = "config/"
destination = "/opt/app/config"
}
# Stage 3: Deploy application binary
provisioner "file" {
source = "builds/application.jar"
destination = "/opt/app/application.jar"
}
# Stage 4: Configure and start the application
provisioner "remote-exec" {
inline = [
"chmod +x /opt/app/config/start.sh",
"sudo systemctl enable application",
"sudo systemctl start application"
]
}
}For infrastructure supporting both Windows and Linux:
locals {
is_windows = var.os_type == "windows" ? true : false
}
resource "aws_instance" "server" {
# Instance configuration...
connection {
type = local.is_windows ? "winrm" : "ssh"
user = local.is_windows ? "Administrator" : "ec2-user"
password = local.is_windows ? var.admin_password : null
private_key = local.is_windows ? null : file("${path.module}/key.pem")
host = self.public_ip
}
provisioner "file" {
source = local.is_windows ? "scripts/windows/" : "scripts/linux/"
destination = local.is_windows ? "C:/temp" : "/tmp"
}
provisioner "remote-exec" {
inline = [
local.is_windows ?
"powershell -Command \"C:/temp/setup.ps1\"" :
"chmod +x /tmp/setup.sh && /tmp/setup.sh"
]
}
}Terraform file provisioners provide a pragmatic solution for transferring files to newly created resources, addressing use cases that fall outside Terraform's declarative model. While they're powerful tools for bootstrapping and initial configuration, they should be used judiciously due to their significant limitations.
The key takeaway is to follow HashiCorp's guidance: use provisioners as a last resort. For most scenarios, alternatives like cloud-init, templated user data, or pre-built images with Packer offer better solutions that align with infrastructure as code principles.
When file provisioners are necessary, understanding their execution model, behavior on failure, and technical mechanisms enables you to use them effectively while mitigating their limitations. By following the best practices and implementation patterns outlined in this guide, you can leverage file provisioners appropriately within your Terraform workflows.
