If you’re still clicking around the AWS console every time you need a new EC2 instance, you’re working way harder than you need to. Terraform gives you a clean, repeatable, version-controlled way to define your cloud infrastructure as code and spin it up in minutes. In this guide, I’ll walk you through, step by step, how to use Terraform to provision an AWS EC2 instance in a way that’s production-ready, secure, and easy to maintain.
We’ll start from a clean machine (macOS, Windows, or Linux), install and configure Terraform and AWS credentials, and then build out a small but realistic Terraform configuration that:
- Creates an EC2 instance in a specific AWS region
- Uses variables, outputs, and remote state–friendly structure
- Applies security best practices for SSH and networking
- Scales to multiple instances or environments (dev/stage/prod)
Along the way, I’ll explain not just what to do, but why it’s done that way—so you can adapt this pattern to your own stacks. We’ll also look at common pitfalls (like provider version mismatches, IAM permission issues, and “stuck” resources), and how to debug and fix them confidently.
You’ll leave with:
- A working Terraform configuration that provisions a real EC2 instance
- An understanding of state, plans, apply/destroy workflows, and modules
- Patterns you can reuse for more complex infrastructure (RDS, VPCs, autoscaling, etc.)
This tutorial assumes you’re in the United States region-wise (for spelling and some AWS region examples), but the instructions work regardless of where your AWS account is based.
Prerequisites: What do you need before using Terraform with AWS EC2?
Before we touch any Terraform configuration, you need a few core pieces in place: an AWS account with the right permissions, the AWS CLI (or equivalent credentials setup), and Terraform itself. Getting these right upfront saves hours of frustration later.
1. AWS account and IAM user with proper permissions
You’ll need an AWS account. If you don’t have one, create it at aws.amazon.com. Once you’re in the AWS Management Console, don’t use your root account for Terraform work. Instead, create an IAM user with programmatic access.
- In the AWS console, go to IAM > Users.
- Click Add users.
- Give it a name like
terraform-user. - Select Access key – Programmatic access.
- Attach permissions:
- For learning: you can temporarily use
AdministratorAccess(not recommended for production). - For production: create a custom policy with the minimum set of permissions (EC2, VPC, IAM roles, etc.).
- For learning: you can temporarily use
- Finish the wizard and download your Access key ID and Secret access key.
Keep these credentials secure. Treat the secret access key like a password—never commit it to Git or paste it into shared docs.
2. AWS CLI and credential configuration
The easiest way to set up your AWS credentials in a Terraform-friendly way is to install the AWS CLI and configure a profile.
Install AWS CLI
- macOS (Homebrew):
brew install awscli - Windows:
- Download the installer from AWS: AWS CLI installation guide.
- Linux:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" unzip awscliv2.zip sudo ./aws/install
Configure AWS credentials
Open a terminal and run:
aws configure
You’ll be prompted for:
- AWS Access Key ID: from the IAM user you created
- AWS Secret Access Key: also from that user
- Default region name: e.g.,
us-east-1orus-west-2 - Default output format: you can use
json
To verify that your credentials work:
aws sts get-caller-identity
You should see a JSON response with your AWS account ID and IAM user. If you get an error, fix this before touching Terraform; otherwise, Terraform won’t be able to talk to AWS.
3. Install Terraform
Terraform is distributed as a single binary. The easiest approach for most developers:
- macOS (Homebrew):
brew tap hashicorp/tap brew install hashicorp/tap/terraform - Windows (chocolatey):
choco install terraform - Linux:
- Use your distro’s package manager or download from Terraform downloads.
Verify installation:
terraform version
You should see something like:
Terraform v1.7.5
on darwin_amd64
The exact version will differ, but try to use a reasonably recent version (1.5+), because some syntax and provider behaviors have changed over time.
4. A code editor and Git (recommended)
While not strictly required, you’ll have a much better experience if you:
- Use a modern editor like VS Code, JetBrains IDEs, or Neovim with Terraform plugins.
- Initialize a Git repository in your Terraform project folder to track changes.
This makes infrastructure a first-class citizen in your development workflow, not an afterthought.
Step-by-step Implementation: How to provision an AWS EC2 instance with Terraform
Now let’s build a working Terraform configuration from scratch. We’ll start simple—one EC2 instance—then refine it with variables, security groups, and outputs. The same patterns stretch to much more complex real-world setups.
Project structure and initialization
Start by creating a new directory for your Terraform project:
mkdir terraform-ec2-demo
cd terraform-ec2-demo
We’ll start with a single file, main.tf, and later break things out if needed.
Define the AWS provider
Create main.tf with the following content:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
profile = "default"
}
What’s going on here?
- The
terraformblock pins both Terraform and the AWS provider version. This avoids surprise breakage when new versions release. - The
provider "aws"block tells Terraform to use your default AWS profile and theus-east-1region. You can override these with variables later.
Initialize the working directory:
terraform init
You should see Terraform downloading the AWS provider and setting up the backend. If there are errors, they’re usually about network connectivity, incompatible provider versions, or missing permissions.
Create a basic EC2 instance resource
Now let’s add an EC2 instance resource to main.tf. We’ll start with hard-coded values, then refactor into variables.
resource "aws_instance" "example" {
ami = "ami-0c02fb55956c7d316" # Amazon Linux 2 in us-east-1 (example, check latest)
instance_type = "t3.micro"
tags = {
Name = "terraform-ec2-demo"
}
}
A few important points:
- AMI is region-specific. The above AMI ID is an example for
us-east-1. For other regions, consult the AWS console or the official AMI catalog. - Instance type
t3.microis a good choice for dev/testing: cheap, supports burstable performance, and often within free-tier limits.
To see what Terraform plans to do:
terraform plan
You should see one aws_instance.example resource to be created. If all looks good:
terraform apply
Terraform will prompt you to confirm. Type yes and press Enter. Terraform will provision the EC2 instance and display the result.
Add key pair and basic SSH access (security-conscious)
An EC2 instance without a key pair is hard to use. But you also don’t want to shove private keys into Terraform. Here’s a sane pattern:
- Generate an SSH key pair on your machine (if you don’t already have one dedicated for this).
ssh-keygen -t rsa -b 4096 -f ~/.ssh/terraform-ec2-demo -C "terraform-ec2-demo" - This creates:
~/.ssh/terraform-ec2-demo(private key)~/.ssh/terraform-ec2-demo.pub(public key)
- We’ll tell Terraform to create an AWS key pair from the public key only.
Update main.tf:
resource "aws_key_pair" "demo_key" {
key_name = "terraform-ec2-demo-key"
public_key = file("~/.ssh/terraform-ec2-demo.pub")
}
resource "aws_instance" "example" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
key_name = aws_key_pair.demo_key.key_name
tags = {
Name = "terraform-ec2-demo"
}
}
Why this pattern?
- The private key stays local; Terraform only sees the public key.
- You can reuse this key pair across multiple instances if desired.
Run:
terraform plan
terraform apply
Terraform will now create the key pair and update (or recreate) the instance if necessary.
Secure the instance with a dedicated security group
By default, AWS will attach the default security group for the VPC, which might allow more than you want. Let’s explicitly define a security group that:
- Allows SSH (port 22) only from your IP
- Allows HTTP (port 80) from the world (optional, if you want to serve web traffic)
First, find your public IP address (from the machine you’ll SSH from). You can use:
curl https://checkip.amazonaws.com
Suppose it returns 203.0.113.45. We’ll template this via a variable instead of hard-coding, so multiple users can adapt it.
Add variables for your IP and instance config
Create variables.tf:
variable "allowed_ssh_cidr" {
description = "CIDR block allowed to SSH into the instance"
type = string
default = "0.0.0.0/0" # For demo only; override with your IP like 203.0.113.45/32
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "instance_name" {
description = "Tag Name for the EC2 instance"
type = string
default = "terraform-ec2-demo"
}
Now add a security group in main.tf:
resource "aws_security_group" "demo_sg" {
name = "terraform-ec2-demo-sg"
description = "Security group for Terraform EC2 demo"
vpc_id = data.aws_vpc.default.id
ingress {
description = "SSH from allowed CIDR"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.allowed_ssh_cidr]
}
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "terraform-ec2-demo-sg"
}
}
data "aws_vpc" "default" {
default = true
}
Update your instance resource to use the security group and variables:
resource "aws_instance" "example" {
ami = "ami-0c02fb55956c7d316"
instance_type = var.instance_type
key_name = aws_key_pair.demo_key.key_name
vpc_security_group_ids = [aws_security_group.demo_sg.id]
tags = {
Name = var.instance_name
}
}
Now run:
terraform plan
Then:
terraform apply
If you don’t override allowed_ssh_cidr, SSH will be open from anywhere (fine for quick demos, not for production). To restrict it to your IP:
terraform apply -var="allowed_ssh_cidr=203.0.113.45/32"
Expose key EC2 attributes via outputs
To avoid constantly running aws ec2 describe-instances, you can have Terraform print useful info after each apply.
Create outputs.tf:
output "instance_id" {
description = "The ID of the EC2 instance"
value = aws_instance.example.id
}
output "public_ip" {
description = "Public IP address of the instance"
value = aws_instance.example.public_ip
}
output "public_dns" {
description = "Public DNS name of the instance"
value = aws_instance.example.public_dns
}
After running terraform apply, Terraform will show these values, making it easy to SSH or connect via a browser.
What does the Terraform “workflow” look like conceptually?
Terraform isn’t a node-based workflow tool like n8n, but conceptually, the dependency graph of resources looks like a flow. You can visualize a simplified version of our configuration like this:
graph TD
A[Provider aws] --> B[data.aws_vpc.default]
B --> C[aws_security_group.demo_sg]
A --> D[aws_key_pair.demo_key]
C --> E[aws_instance.example]
D --> E
Terraform automatically determines the order based on references (e.g., aws_security_group.demo_sg.id used in the instance resource). You don’t need to script the sequence manually—this is one of the big wins of Terraform’s declarative model.
Testing & Output: How do you verify your EC2 instance and Terraform configuration?
Now you’ve got the basics working, you want to be sure everything behaves as expected. That means verifying both:
- The EC2 instance is reachable and configured as intended.
- The Terraform workflow (init/plan/apply/destroy) behaves predictably under changes.
Scenario 1: Basic validation (is the instance actually up?)
After running terraform apply, note the public_ip and public_dns outputs. From your terminal:
ssh -i ~/.ssh/terraform-ec2-demo ec2-user@<PUBLIC_IP>
Common username patterns:
- Amazon Linux:
ec2-user - Ubuntu:
ubuntu - Debian:
adminordebian(check AMI docs)
If you cannot connect:
- Check that
allowed_ssh_cidrincludes your current IP address. - Verify your private key path and permissions:
chmod 600 ~/.ssh/terraform-ec2-demo - Make sure the instance status is running in the AWS console.
Scenario 2: Test security group rules
To verify HTTP access, install a simple web server on the instance:
sudo yum update -y # for Amazon Linux
sudo yum install -y httpd
sudo systemctl enable httpd
sudo systemctl start httpd
echo "<h1>Hello from Terraform EC2</h1>" | sudo tee /var/www/html/index.html
Now visit http://<PUBLIC_IP> in your browser. You should see the HTML heading. If not:
- Check that port 80 is allowed in
aws_security_group.demo_sg. - Ensure the instance’s public IP hasn’t changed (e.g., after recreation).
Scenario 3: Changing configuration and observing diffs
One of Terraform’s strengths is showing you proposed changes before applying them. Let’s test that.
- Open
variables.tfand change:default = "t3.micro"to:default = "t3.small" - Run:
terraform plan - Terraform will show that the instance type will change, usually requiring the instance to be replaced (destroyed and recreated).
If you accept:
terraform apply
Terraform will gracefully replace the instance and update any outputs. This is the “infrastructure diff” that makes Terraform so powerful—you always see what will happen before it happens.
Scenario 4: Destroying infrastructure cleanly
When you’re finished, you don’t want orphaned resources burning money. Destroy everything Terraform created:
terraform destroy
Review the plan and type yes. Terraform will delete the instance, security group, and key pair.
If resources fail to destroy:
- Check for manual changes made via the console (e.g., attaching other resources).
- Use
terraform state show <resource>to inspect problematic resources. - As a last resort, you can manually delete in AWS and then run
terraform applyagain to let Terraform reconcile.
Regression checks: What to validate on every change
In real projects, I recommend a simple checklist whenever you modify Terraform:
- Does
terraform validatepass? (syntax and basic schema checks) - Does
terraform planshow only the changes you expect? - After
apply, can you:- Reach the instance via SSH?
- Confirm security group rules behave as designed?
- Verify tags and naming conventions?
Advanced Configuration: How do you make this Terraform + EC2 setup production-ready?
Once you’re comfortable spinning up a single EC2 instance, the next step is hardening and scaling your setup. This is where Terraform really shines compared to point-and-click in the console.
1. Use variables and tfvars for environment-specific config
Hard-coding values is fine for a demo, but in production you’ll have multiple environments (dev, staging, prod) and often multiple regions. Use .tfvars files to manage per-environment settings.
Example dev.tfvars:
instance_type = "t3.micro"
instance_name = "terraform-ec2-dev"
allowed_ssh_cidr = "203.0.113.45/32"
Example prod.tfvars:
instance_type = "t3.large"
instance_name = "terraform-ec2-prod"
allowed_ssh_cidr = "203.0.113.45/32"
Apply with:
terraform apply -var-file="dev.tfvars"
terraform apply -var-file="prod.tfvars"
This pattern keeps your Terraform code DRY and makes environment drift obvious.
2. Remote state and locking (for teams)
By default, Terraform stores state locally in terraform.tfstate. That’s fine when you’re the only one applying changes, but it breaks down once multiple people or pipelines touch the same infrastructure.
Use a remote backend, typically S3 + DynamoDB, for:
- Centralized state storage
- State locking to prevent concurrent applies
- Versioning and recovery
Example backend configuration in main.tf:
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "ec2-demo/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
You’ll need to create the S3 bucket and DynamoDB table beforehand (you can even use Terraform to bootstrap these using a separate “bootstrap” workspace).
3. User data and configuration management
So far, we’ve provisioned a vanilla EC2 instance, then manually SSH’d in to install packages. In real setups, you want the instance to configure itself at boot. That’s where user_data comes in.
Example: install and start a simple web app on boot:
resource "aws_instance" "example" {
ami = "ami-0c02fb55956c7d316"
instance_type = var.instance_type
key_name = aws_key_pair.demo_key.key_name
vpc_security_group_ids = [aws_security_group.demo_sg.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl enable httpd
systemctl start httpd
echo "<h1>Hello from Terraform user_data</h1>" > /var/www/html/index.html
EOF
tags = {
Name = var.instance_name
}
}
Why this matters:
- Your instances become reproducible: you can destroy and recreate them without manual work.
- You can build immutable infrastructure patterns where code changes trigger new instances rather than long-lived pets.
For more complex setups, consider:
- Baking AMIs with Packer (pre-installed software).
- Using configuration management tools (Ansible, Chef) triggered via user_data.
4. Security best practices
Some key security improvements beyond our simple demo:
- Avoid 0.0.0.0/0 for SSH: Always restrict SSH to a known IP or VPN range.
- Use IAM roles for EC2 instead of embedding AWS credentials on the instance. Example:
- Create an IAM role with necessary permissions (e.g., S3 access).
- Associate it via
iam_instance_profilein theaws_instanceresource.
- Use security groups as “firewall as code”: Keep rules as tight as possible and avoid editing them manually in the console.
- Encrypt volumes by default using EBS encryption.
Example of enabling EBS encryption on the root volume:
resource "aws_instance" "example" {
# ...
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
}
5. Scaling out: multiple instances and modules
If you need multiple similar instances (e.g., app servers behind a load balancer), don’t copy-paste aws_instance blocks. Use for_each or modules.
Simple example with for_each:
variable "instances" {
type = map(object({
instance_type = string
}))
default = {
"app-1" = { instance_type = "t3.micro" }
"app-2" = { instance_type = "t3.micro" }
}
}
resource "aws_instance" "app" {
for_each = var.instances
ami = "ami-0c02fb55956c7d316"
instance_type = each.value.instance_type
key_name = aws_key_pair.demo_key.key_name
vpc_security_group_ids = [aws_security_group.demo_sg.id]
tags = {
Name = each.key
}
}
This pattern is extremely powerful for clusters of similar resources (workers, web servers, etc.).
6. Observability and maintenance
In production, you’ll want visibility into your instances and Terraform runs:
- CloudWatch:
- Enable detailed instance metrics.
- Set alarms for CPU, disk, network, etc.
- Terraform logs:
- Use
TF_LOGenvironment variable for debugging failed applies:export TF_LOG=DEBUG terraform apply
- Use
- State file hygiene:
- Never hand-edit
terraform.tfstate. - Use
terraform state mvorterraform state rmif you need to adjust state.
- Never hand-edit
Conclusion: What have you actually achieved with Terraform + EC2?
By this point, you’ve done more than just “spin up a server.” You’ve:
- Defined your AWS infrastructure (at least one EC2 instance, security group, and key pair) in code.
- Learned how Terraform tracks state and calculates diffs with
planandapply. - Exposed key outputs like IP and DNS names for quick access.
- Locked down access via security groups and SSH keys instead of password-based logins.
- Started thinking in terms of reproducible infrastructure: destroy and rebuild whenever you need to.
In my view, this is the real value of Terraform: not that you “automate” EC2 creation, but that your infrastructure becomes versioned, reviewable, and testable—just like your application code. You can roll back changes, track who changed what, and share a single source of truth across teammates and environments.
From here, the natural next steps are:
- Introduce VPCs, subnets, and load balancers (ALB/NLB) managed by Terraform.
- Use Terraform modules to encapsulate and reuse patterns (e.g., a standard “web app” module).
- Wire Terraform into your CI/CD pipelines so infrastructure changes are reviewed and applied automatically.
- Layer in more advanced security: IAM roles, SSM Session Manager instead of direct SSH, encrypted volumes, and secrets management.
Once you’re comfortable with these patterns on EC2, you can reuse the same Terraform skills across the rest of AWS: RDS, ECS, EKS, Lambda, and beyond. The core loop—init, plan, apply, destroy—stays the same. Only the resources and their relationships change.
FAQs: Common Terraform + AWS EC2 questions and real-world answers
1. How do I choose the right AMI for my Terraform EC2 instance?
This is a big one, because AMIs are region-specific and update frequently. Hard-coding an AMI ID is fine for a quick demo, but for real-world use you should either:
- Use an AMI data source that looks up the latest AMI by filters, or
- Manage your own AMIs (e.g., with Packer) and reference them explicitly.
Example using a data source to get the latest Amazon Linux 2 AMI:
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "example" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = var.instance_type
# ...
}
This way, your Terraform code doesn’t break when AWS deprecates older AMIs. Just be careful with automatic AMI upgrades in production—sometimes you want a fixed AMI per release to avoid surprises.
2. How should I manage SSH keys with Terraform without leaking secrets?
You have three main patterns:
- Generate SSH keys locally (like we did) and reference only the public key via
aws_key_pair. This is usually the safest and simplest option.- Pros: private key never leaves your machine.
- Cons: harder to share across a team unless you use a central key management approach.
- Use existing key pairs managed outside Terraform. In that case, you might just pass
key_nameas a variable and not manageaws_key_pairin Terraform at all.- Pros: separation of responsibilities—ops team manages keys, Terraform manages infrastructure.
- Cons: less visibility in Terraform; you must ensure keys exist before apply.
- Generate keys in CI and inject via variables or secrets manager. I rarely recommend this unless you have a very mature secrets pipeline, because private keys can easily end up in logs or state if you’re not careful.
The non-negotiable rule: never store private keys (or any secret) in Terraform state or in your Git repo. Use file() to read them locally if you must, or let AWS Systems Manager Session Manager handle access instead of SSH.
3. How do I handle Terraform errors like “resource already exists” or “resource in use”?
These usually come from drift between Terraform state and the real world. A few patterns:
- Resource already exists:
- Maybe the resource was created manually or by a different Terraform project.
- Solution: either import it into your current state with
terraform import, or rename resources to avoid conflicts.
- Resource in use (e.g., can’t delete security group):
- Check dependencies: perhaps another instance or ENI is still attached.
- Use
terraform state showand AWS console to see who’s using what. - Destroy or detach dependents first, or let Terraform handle it via its dependency graph.
- Timeouts or partial failures:
- Terraform might mark resources as “tainted” if creation fails mid-way.
- Use
terraform taint(in older versions) orterraform apply -replace=<address>to force recreation.
In general, treat Terraform state as the source of truth. If you must change something manually in AWS, expect to reconcile it back into Terraform via import or by re-running apply.
4. How do I prevent my Terraform EC2 setup from incurring unexpected costs?
A few practical techniques I use in real projects:
- Use smaller instance types for dev:
t3.micro,t3.small, etc. - Tag everything with environment, owner, and cost center:
tags = { Name = var.instance_name Environment = "dev" Owner = "your-name" } - Use separate AWS accounts for dev/test vs. production to keep billing and permissions clean.
- Schedule automatic shutdown via CloudWatch Events / EventBridge or Lambda if instances are only needed during work hours.
- Destroy environments when not in use:
- For short-lived test environments, make
terraform destroypart of the workflow.
- For short-lived test environments, make
Also, regularly review the AWS Cost Explorer with filters by tag and service. If an EC2 instance from your Terraform project runs unexpectedly large or long, you’ll see it quickly.
5. What’s the best way to integrate Terraform + EC2 into a CI/CD pipeline?
The pattern I see work well in mature teams looks like this:
- Keep Terraform in its own repo or in an
infra/directory of your main repo. - Use remote state (S3 + DynamoDB) so CI and humans share the same state.
- Pipeline stages:
- Validate: run
terraform fmt -check,terraform validate. - Plan: run
terraform plan -out=plan.tfplanand store the plan as an artifact. - Review: require manual or code review approval on the plan (e.g., via PR comments).
- Apply: once approved, run
terraform apply plan.tfplanfrom CI.
- Validate: run
- Use separate workspaces or directories for
dev,staging, andprodto avoid accidentally applying dev changes to prod. - Credential management:
- Use short-lived IAM roles assumed by CI, not static long-lived keys.
The net effect is that infrastructure changes go through the same rigor as application code: version control, reviews, and automated checks. Terraform’s idempotent, declarative model makes it an excellent fit for this style of delivery.