Deploying EC2 with Terraform: what developers should know
When your application outgrows a single developer machine, EC2 becomes the backbone of your infrastructure. But clicking through the AWS console for each server means configuration drift, manual toil, and the constant risk of human error. Deploying EC2 with Terraform gives us repeatable configuration, version control, and the ability to scale without breaking a sweat—provided we know the patterns that work in production.
Over the past 27 days of this IaC journey, we've built foundational skills. Now we're applying them to one of the most common workloads: launching and managing compute instances at scale. This article covers the patterns we use across e-commerce, SaaS, and trading platforms to provision EC2 reliably.
Finding the AMI: using data sources for the latest Amazon Linux
Every EC2 instance starts from an Amazon Machine Image (AMI). Hardcoding an AMI ID in your Terraform configuration is a trap—AMI IDs are region-specific, and new versions roll out regularly with security patches. We use data sources to fetch the latest AMI dynamically, ensuring consistency across environments and regions.
The canonical approach is to query AWS for the most recent image matching your criteria. Here's how we find the latest Amazon Linux 2 AMI owned by Amazon:
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"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
output "ami_id" {
value = data.aws_ami.amazon_linux_2.id
}This approach handles version management automatically. When AWS releases a patched AMI, your next terraform apply picks it up without manual intervention. The most_recent = true flag ensures we always get the latest, and the filters lock us to a known family (Amazon Linux 2, HVM virtualization, EBS root device).
For teams running custom images, you'd replace the data source with a reference to an AMI built and tagged by your CI/CD pipeline. The principle remains: never hardcode infrastructure identifiers.
Launch templates: configuring instance behavior at boot
A launch template is a reusable blueprint for EC2 instances. It centralizes instance type, storage, IAM roles, security groups, and user data—everything needed to spin up a server. Launch templates are essential when using Auto Scaling groups, as they ensure consistency across instances.
Here's a production-grade launch template for a web application:
resource "aws_iam_role" "ec2_role" {
name = "ec2-app-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ssm_access" {
role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_role_policy" "cloudwatch_logs" {
name = "cloudwatch-logs-policy"
role = aws_iam_role.ec2_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Effect = "Allow"
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
resource "aws_iam_instance_profile" "ec2_profile" {
name = "ec2-app-profile"
role = aws_iam_role.ec2_role.name
}
resource "aws_launch_template" "app_server" {
name_prefix = "app-server-"
image_id = data.aws_ami.amazon_linux_2.id
instance_type = "t3.medium"
iam_instance_profile {
arn = aws_iam_instance_profile.ec2_profile.arn
}
root_block_device {
volume_type = "gp3"
volume_size = 30
delete_on_termination = true
encrypted = true
}
monitoring {
enabled = true
}
user_data = base64encode(templatefile("${path.module}/user_data.sh", {
environment = var.environment
app_version = var.app_version
}))
tag_specifications {
resource_type = "instance"
tags = {
Name = "app-server"
Environment = var.environment
}
}
lifecycle {
create_before_destroy = true
}
}
Several details matter here. The IAM role grants permissions for Systems Manager Session Manager (we'll cover this next) and CloudWatch Logs. Encryption is enabled on the root volume by default. The monitoring = true flag enables detailed CloudWatch metrics. The name_prefix and create_before_destroy lifecycle rule ensure smooth updates when the launch template changes.
User data is a shell script that runs once at boot—use it to install the agent, pull your application code, and start services. Keep user data lean; prefer configuration management tools or container images for complex setup.
Auto Scaling groups: self-healing instances across availability zones
An Auto Scaling group manages a fleet of instances, replacing failed ones and scaling in response to demand. For stateless workloads (web servers, API backends, workers), this is your foundation for resilience and cost efficiency.
Here's a production Auto Scaling group that spans multiple availability zones:
resource "aws_autoscaling_group" "app_servers" {
name = "app-servers-asg"
vpc_zone_identifier = var.private_subnet_ids
min_size = 2
max_size = 10
desired_capacity = 3
health_check_type = "ELB"
health_check_grace_period = 300
launch_template {
id = aws_launch_template.app_server.id
version = "$Latest"
}
tag {
key = "Name"
value = "app-server-asg"
propagate_at_launch = true
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_autoscaling_policy" "scale_up" {
name = "app-servers-scale-up"
autoscaling_group_name = aws_autoscaling_group.app_servers.name
adjustment_type = "PercentChangeInCapacity"
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 70.0
}
}The ASG launches instances in the private subnets across multiple zones for isolation and fault tolerance. The health_check_type = "ELB" ensures that if a load balancer marks an instance as unhealthy, the ASG replaces it. health_check_grace_period gives newly launched instances time to boot and become healthy before health checks start.
Target tracking policies handle scaling automatically—in this case, the ASG maintains average CPU at 70%. This approach is simpler and more stable than step scaling or manual management.
No SSH keys: using SSM Session Manager instead
Distributing SSH keys across a team is a security and operational nightmare. SSH keys left on developer machines can be compromised, rotated keys create rolling confusion, and there's no audit trail of who accessed what. Systems Manager Session Manager provides a better path: temporary, audited shell access through the AWS API and your identity provider.
Session Manager works through the SSM agent, which runs on every Amazon Linux 2 AMI by default. The launch template above includes the policy AmazonSSMManagedInstanceCore, which grants the EC2 instance permission to contact SSM. On the IAM side, your developers need permission to start sessions:
resource "aws_iam_policy" "ssm_session_policy" {
name = "ssm-session-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"ssm:StartSession",
"ssm:TerminateSession",
"ssm:ResumeSession"
]
Effect = "Allow"
Resource = [
"arn:aws:ssm:*:*:document/AWS-StartInteractiveCommand",
"arn:aws:ec2:*:*:instance/*"
]
},
{
Action = [
"ec2messages:GetMessages",
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Effect = "Allow"
Resource = "*"
}
]
})
}To access an instance, your developers run:
aws ssm start-session --target i-1234567890abcdef0 --region us-east-1CloudTrail logs every session start, stop, and command. If you need to audit who ran what, you have a complete record. There's no SSH key to rotate, no bastion host to maintain, and access integrates with your AWS IAM role structure.
Common mistakes
- Hardcoding AMI IDs: Different regions have different AMI IDs. A hardcoded ID breaks when you deploy to a new region. Use data sources and let Terraform query the current AMI.
- Ignoring health checks: Without proper health check configuration, the ASG doesn't know when an instance is broken. Set
health_check_type = "ELB"and pair your ASG with a load balancer. - User data too large: User data runs synchronously at boot and has a size limit. If you're installing 500 MB of dependencies, use a custom AMI instead.
- Keeping SSH key infrastructure: Migrating away from SSH keys takes upfront effort, but Session Manager eliminates a whole class of key management headaches. The investment pays dividends.
- Over-provisioning instances: Start conservative (t3.micro or t3.small for testing) and scale based on actual metrics, not guesses. The ASG handles headroom automatically.
- Forgetting encryption: Encryption on EBS volumes and in transit should be the default, not an afterthought. Enable it in the launch template.
Key takeaways
- Use AMI data sources to fetch the latest image matching your criteria, ensuring consistency and automatic security updates across regions.
- Launch templates centralize instance configuration—instance type, IAM roles, user data, storage—making it repeatable and version-controlled.
- Auto Scaling groups provide self-healing fleets across multiple availability zones, replacing failed instances and scaling based on metrics.
- SSM Session Manager replaces SSH key management with temporary, audited access through AWS IAM, eliminating key distribution overhead and providing full audit trails.
- Always encrypt EBS volumes, enable detailed monitoring, and pair Auto Scaling groups with load balancers for health awareness.






Comments
Loading comments…