Terraform user_data on AWS EC2 - inline script, file(), templatefile(), the 16 KB limit, first-boot-only, and why a change recreates the instance (AWS provider 6.69)
TL;DR
user_datais a string that EC2 passes to cloud-init on the instance's first boot. Start it with#!/bin/bashfor a shell script or#cloud-configfor cloud-init YAML; without a recognised first line nothing runs.- Three ways to write it: inline with
<<-EOT ... EOT,file("${path.module}/setup.sh")for a script you keep next to the code,templatefile("setup.sh.tftpl", { port = var.app_port })when the script needs variables. - Limits: 16 KB of raw script before base64 (EC2 encodes it, adding about 33%); runs only on the first boot, not after a reboot; output lands in
/var/log/cloud-init-output.log. - Changing it is not free: by default the AWS provider stops and starts the instance (new public IP unless you have an Elastic IP); with
user_data_replace_on_change = trueit destroys and recreates it. - Plans below are from Terraform 1.16.2 with AWS provider 6.69.1 on 2026-10-17; a plan with mock credentials takes 7210 ms and shows the rendered script exactly as the instance will receive it.
- Not your exact case? The closest post on this site is Copy files to EC2 and S3 bucket using Terraform.
The 2023 version of this post walked through the video. The questions that bring people here now are specific: "my user_data did not run", "how do I pass a variable into it", "why did Terraform recreate my instance". Those have precise answers, so for this rewrite I put all three forms in one configuration and read the plan, which prints the script byte for byte.
Table of Content
- What user data is and when it runs
- One configuration with all three forms
- What the plan shows - the exact script the instance gets
- templatefile - loops and variables in the script
- What happens when you change user data
- Debugging - my user data did not run
- What broke when I ran this
- Conclusion
1. What user data is and when it runs
| Fact | Value | Source |
|---|---|---|
| Maximum size | 16 KB raw, before base64 encoding | EC2 user data |
| Base64 growth | ceil(n/3)*4, about 33% larger | same page |
| When it runs | first boot only, by default | same page |
| Where the output goes | /var/log/cloud-init-output.log on the instance | same page |
| Recognised first lines | #!/bin/bash (script), #cloud-config (YAML) | cloud-init formats |
| Change behaviour in Terraform | stop/start by default, replace with user_data_replace_on_change | aws_instance |
EC2 stores the string in the instance metadata; cloud-init on the AMI reads it at first boot and decides from the first line what to do with it. Terraform's job is only to deliver the string, which is why all the "did not run" problems in section 6 are on the instance side.
2. One configuration with all three forms
1terraform {
2 required_providers {
3 aws = { source = "hashicorp/aws", version = "~> 6.0" }
4 }
5}
6provider "aws" {
7 region = "eu-north-1"
8}
9
10variable "app_port" { default = 8080 }
11
12# 1. inline heredoc
13resource "aws_instance" "inline" {
14 ami = "ami-0abcdef1234567890"
15 instance_type = "t3.micro"
16 user_data = <<-EOT
17 #!/bin/bash
18 apt-get update -y
19 apt-get install -y nginx
20 systemctl enable --now nginx
21 EOT
22}
23
24# 2. a script file next to the code
25resource "aws_instance" "from_file" {
26 ami = "ami-0abcdef1234567890"
27 instance_type = "t3.micro"
28 user_data = file("${path.module}/setup.sh")
29}
30
31# 3. a template with variables
32resource "aws_instance" "templated" {
33 ami = "ami-0abcdef1234567890"
34 instance_type = "t3.micro"
35 user_data = templatefile("${path.module}/setup.sh.tftpl", { port = var.app_port, packages = ["nginx", "git"] })
36 user_data_replace_on_change = true
37}
setup.sh is a plain script, setup.sh.tftpl is the same idea with placeholders:
1#!/bin/bash
2mkdir -p /opt/app
3echo "hello from file()" > /opt/app/index.html
1#!/bin/bash
2apt-get update -y
3%{ for p in packages ~}
4apt-get install -y ${p}
5%{ endfor ~}
6sed -i "s/listen 80/listen ${port}/" /etc/nginx/sites-available/default
7systemctl restart nginx
The <<-EOT form (with the dash) strips the common leading indentation, so the script can sit indented inside the resource and still start with #!/bin/bash in column one, which cloud-init requires. The 2023 video used ${file("")} inside a string; that still works but file() on its own is the idiomatic form and avoids a double-interpolation warning.

3. What the plan shows - the exact script the instance gets
The plan prints user_data in full, which is the fastest way to check what the instance will run. I ran it with mock credentials and skip_credentials_validation, so no instance was created, but the rendering is identical:
1$ terraform plan
2 # aws_instance.from_file will be created
3 + resource "aws_instance" "from_file" {
4 + instance_type = "t3.micro"
5 + user_data = <<-EOT
6 #!/bin/bash
7 mkdir -p /opt/app
8 echo "hello from file()" > /opt/app/index.html
9 EOT
10 + user_data_replace_on_change = false
11 }
12
13 # aws_instance.inline will be created
14 + resource "aws_instance" "inline" {
15 + user_data = <<-EOT
16 #!/bin/bash
17 apt-get update -y
18 apt-get install -y nginx
19 systemctl enable --now nginx
20 EOT
21 + user_data_replace_on_change = false
22 }
23
24 # aws_instance.templated will be created
25 + resource "aws_instance" "templated" {
26 + user_data = <<-EOT
27 #!/bin/bash
28 apt-get update -y
29 apt-get install -y nginx
30 apt-get install -y git
31 sed -i "s/listen 80/listen 8080/" /etc/nginx/sites-available/default
32 systemctl restart nginx
33 EOT
34 + user_data_replace_on_change = true
35 }
36
37Plan: 3 to add, 0 to change, 0 to destroy.
The template's loop became two apt-get install lines and ${port} became 8080. Sizes of the three scripts as sent, read from the plan JSON:
| Form | Raw bytes | After base64 | Of the 16 KB limit |
|---|---|---|---|
| inline | 84 | 112 | 0.5% |
| file() | 77 | 104 | 0.5% |
| templatefile() | 171 | 228 | 1.0% |
A real bootstrap script with a few packages and config files is typically 2 to 5 KB; if you are near 16 KB, download the rest from S3 inside the script (aws s3 cp with an instance role) instead of inlining it. Copying files to EC2 and S3 with Terraform covers the S3 side.

4. templatefile - loops and variables in the script
templatefile takes a path and a map; inside the file ${var} substitutes and %{ for } ... %{ endfor } loops. Two details that trip people up:
- A literal
${in your shell script must be written$${or Terraform tries to interpolate it.echo "$${HOME}"in the template becomesecho "${HOME}"on the instance. The same applies to%{. - The
~in%{ for p in packages ~}strips the newline after the directive, which is why the rendered script has no blank lines between theapt-getcalls. Without it the script still works, it just has empty lines.
The rendered result can be checked without a plan at all:
1output "rendered" {
2 value = templatefile("${path.module}/setup.sh.tftpl", { port = var.app_port, packages = ["nginx", "git"] })
3}
terraform plan then prints the 7-line script under Changes to Outputs. The older template_file data source from the 2023 version of this post is deprecated; templatefile() is the replacement and needs no provider. I go deeper on templates in Terraform templatefile.
5. What happens when you change user data
This is the part of user_data that costs people an outage. From the aws_instance documentation, an update to user_data on an existing instance triggers a stop and start by default. Stop/start means a new host, a new public IPv4 unless an Elastic IP is attached, and the instance store (if any) wiped. And because cloud-init runs user data on first boot only, the new script does not run after the stop/start; the instance comes back with the old configuration and a new user data string it will never execute.
user_data_replace_on_change = true makes the behaviour match what people expect: the instance is destroyed and recreated, the new script runs on the new instance's first boot. In the plan above that is the user_data_replace_on_change = true line on the templated instance. Use it for stateless instances behind an autoscaling group or load balancer; for a stateful instance, move the configuration into Ansible and leave user data for bootstrapping only.
6. Debugging - my user data did not run
In order of how often I have seen each one:
- No shebang. The first line must be
#!/bin/bash(or#cloud-config). A script that starts with a comment or a blank line is ignored silently. The<<-EOTheredoc protects you from indentation; afile()script still has to start correctly. - It did run, on the first boot, and you were looking after a reboot. Check
/var/log/cloud-init-output.log; every line of output from the script is there, includingapt-geterrors. - The AMI has no cloud-init. Minimal or custom images may not; Amazon Linux, Ubuntu, Debian and the Windows AMIs (EC2Launch) do.
- The script ran before the network was up and
apt-get updatefailed. cloud-init runs user data in its final stage, after networking, but asedon a file installed by a package that failed to install gives a misleading error further down the log. - You changed
user_dataand expected it to re-run. See section 5.
To check what the instance received, from inside the instance:
1curl -s http://169.254.169.254/latest/user-data
That prints the decoded script. If it matches the plan and the log shows it ran, the problem is in the script, not in Terraform. Setting up the instance itself is in EC2 instance with Terraform, and SSH into the EC2 instance covers getting onto the box to read the log.

7. What broke when I ran this
I ran the configuration on Terraform 1.16.2 with AWS provider 6.69.1 on 2026-10-17, with mock credentials so no instance was launched.
1. The plan took 7210 ms with no network calls. The AWS provider is large and initialising it dominates even a mock plan. On CI, terraform plan for a handful of instances is a 7 to 10 second floor before any API call; budget for it rather than wondering whether the plan hung.
2. terraform output rendered printed Warning: No outputs found. Outputs only exist after an apply writes them to state; a plan shows them under Changes to Outputs but does not store them. To see a rendered template without applying, read it from the plan, or use terraform console with the templatefile expression.
3. The base64 sizes were one byte off from my arithmetic. I expected the 77-byte script to encode to 103 bytes (77 times 4/3); the plan JSON says 104, because base64 pads to a multiple of 4. AWS's own formula, ceil(n/3)*4, gives 104. Irrelevant at this size, but if you are computing how close a script is to the 16 KB limit, use the ceiling formula.
4. I replaced the 2023 template_file data source rather than re-testing it. It needs the separate hashicorp/template provider, which HashiCorp has archived; templatefile() is built into Terraform and renders the same template with no provider, which is what the plan above uses.
8. Conclusion
To summarise:
user_datais the script cloud-init runs on the first boot, delivered by Terraform as a string.- Inline for a few lines,
file()for a script you maintain,templatefile()when it needs variables or loops. - 16 KB raw limit, first boot only, output in
/var/log/cloud-init-output.log. - Changing it stops and starts the instance by default and does not re-run the script; set
user_data_replace_on_change = truefor stateless instances. - Read the script back in the plan output or from the metadata endpoint before blaming Terraform.
For configuration that must change after the first boot, the pattern is Terraform for the instance and Ansible for the inside of it, which is the split I use in the Terraform and Ansible production project.
Did this save you a debugging session? Stay in the loop -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.