
This article is part of our Terraform Provisioners guide.
Terraform file provisioners copy files from your local machine onto a resource right after Terraform creates it. That's handy when you need to drop a config file or a script onto a fresh server during bootstrap. But the file provisioner sits outside Terraform's declarative model, and HashiCorp recommends using provisioners only as a last resort. This article covers how the file provisioner works, where it causes trouble, and what to reach for instead.
Sometimes you need a config file or a script on a server right after it boots, and there's no clean Terraform-native way to put it there. That's the gap file provisioners fill. They're part of Terraform's broader provisioner concept, which 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 copies files or directories from the machine running Terraform to resources it just created. It's one of three built-in provisioners:
Here's where file provisioners sit in Terraform's execution model:
when = destroyThat last point matters a lot: provisioner actions are not recorded in state files, so Terraform can't detect or fix drift in any file you manage through them.
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 set up connections at the resource level, where they apply to every provisioner, or inline for each one:
# 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 has to exist already, so you usually create 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. That advice comes down to a few limitations:
Credential management poses significant risks:
Network security issues arise:
File permissions need careful handling:
on_failure = continue when appropriateFile provisioners tend to run into a handful of recurring 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:
In most cases, a few other approaches beat a file provisioner:
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:
Here's how you'd use template files to generate config on the fly:
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"
]
}
}File provisioners get a file onto a server when nothing else in your setup will. They handle the cases Terraform's declarative model doesn't cover, which is exactly why they come with so many caveats. Use them for bootstrapping, but don't make them the backbone of how you configure machines.
Follow HashiCorp's guidance here: use provisioners as a last resort. For most jobs, cloud-init, templated user data, or a pre-built Packer image will do the same work without the connection blocks, the open ports, and the state-tracking gap.
When you do need a file provisioner, the rest matters: know that it runs only on create, that a failure taints the resource, and that nothing it does shows up in state. Keep those facts in mind and the patterns above will keep you out of the common pitfalls.
