Terraform — Provision an AWS EC2 Instance
Provisioning cloud infrastructure typically involves manual steps in a console or scripting against APIs, which can lead to inconsistencies, configuration…
Provisioning cloud infrastructure typically involves manual steps in a console or scripting against APIs, which can lead to inconsistencies, configuration drift, and difficulty in replication. Terraform, an Infrastructure as Code (IaC) tool by HashiCorp, addresses these challenges by allowing infrastructure to be defined and provisioned using a declarative configuration language. This article details the process of provisioning an Amazon EC2 instance on AWS using Terraform, covering essential configuration, security considerations, and lifecycle management.
Initial Setup and AWS Provider Configuration
Before defining EC2 resources, Terraform needs to be installed and configured to interact with your AWS account. Ensure you have the AWS CLI configured with appropriate credentials (e.g., via aws configure or environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) that have permissions to create EC2 instances and related resources. Terraform uses these credentials implicitly.
A basic Terraform configuration starts with defining the required providers and their versions. The aws provider is essential for interacting with AWS services. The region parameter specifies where your resources will be provisioned.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Specify a compatible version range
}
}
}
provider "aws" {
region = "us-east-1" # Or your preferred AWS region, e.g., "eu-west-1"
# profile = "my-aws-profile" # Uncomment and specify if using a named AWS CLI profile
}
The ~> 5.0 version constraint ensures that Terraform will use the latest patch release for version 5 (e.g., 5.1, 5.2.1) but will not upgrade to version 6.0, which might introduce breaking changes. The profile argument in the provider block is optional and allows you to specify a named profile from your ~/.aws/credentials file, overriding default environment variable or shared credential file settings.
Defining the EC2 Instance Resource
The core of provisioning an EC2 instance lies in the aws_instance resource block. This block requires several key arguments, such as the Amazon Machine Image (AMI) ID, the instance type, and tags for identification.
AMI Selection
Choosing the correct AMI is crucial. AMIs are regional, so an AMI ID valid in us-east-1 might not be valid in eu-west-1. You can find public AMI IDs using the AWS console, AWS CLI (aws ec2 describe-images), or by using a Terraform data source. For example, to find the latest Ubuntu Server 22.04 LTS AMI for us-east-1:
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical's owner ID
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # Dynamically fetch the latest Ubuntu AMI ID
instance_type = "t3.micro"
# ... other configurations
}
Using a data source for AMIs is a best practice, as it ensures your configuration always deploys the latest version of a specified OS, reducing manual AMI ID updates.
Instance Type and Tags
The instance_type dictates the CPU, memory, and networking capacity of the EC2 instance. Common types include t2.micro, t3.micro (often free tier eligible), m5.large, or c6g.medium. Tags are key-value pairs that help organize and identify your AWS resources. They are essential for cost allocation, automation, and access control.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro" # Free tier eligible in some regions for a limited time
tags = {
Name = "web-server-prod-01"
Environment = "Production"
ManagedBy = "Terraform"
}
}
Network and Security Configuration
For an EC2 instance to be accessible and secure, it needs appropriate network and security group configurations. This typically involves attaching it to a Virtual Private Cloud (VPC) subnet and assigning a security group.
VPC and Subnet
By default, EC2 instances launch into the default VPC and a default subnet in the specified region. However, for production environments, it's best practice to explicitly define or select a non-default VPC and subnet. If you don't define a VPC, Terraform will pick a default one if available. For explicit control:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "main-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true # Automatically assign a public IP
tags = {
Name = "public-subnet-1a"
}
}
resource "aws_instance" "web" {
# ... other configurations
subnet_id = aws_subnet.public.id
# Associate a public IP, though map_public_ip_on_launch on subnet usually handles it
associate_public_ip_address = true
}
Security Groups
Security groups act as virtual firewalls for your EC2 instances, controlling inbound and outbound traffic. You define rules that permit traffic on specific ports from specific source IP addresses or other security groups.
resource "aws_security_group" "web_sg" {
name = "web_sg"
description = "Allow HTTP/HTTPS and SSH inbound traffic"
vpc_id = aws_vpc.main.id # Associate with our custom VPC
ingress {
description = "SSH from anywhere"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # WARNING: 0.0.0.0/0 for SSH is insecure for production
}
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1" # -1 means all protocols
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound traffic"
}
tags = {
Name = "web-security-group"
}
}
resource "aws_instance" "web" {
# ... other configurations
vpc_security_group_ids = [aws_security_group.web_sg.id]
# ... other configurations
}
Security Note: Allowing SSH (port 22) from 0.0.0.0/0 is highly discouraged for production environments due to security risks. Restrict SSH access to known IP ranges or use a bastion host.
SSH Key Pair and User Data
To securely access your EC2 instance via SSH, you need to associate it with an SSH key pair. You can either import an existing public key or have Terraform create a new one.
Associating an SSH Key
First, create an SSH key locally (e.g., ssh-keygen -t rsa -b 4096 -f ~/.ssh/my-ec2-key). Then, import the public key to AWS using Terraform:
resource "aws_key_pair" "deployer" {
key_name = "my-ec2-key-pair"
public_key = file("~/.ssh/my-ec2-key.pub") # Path to your public key
}
resource "aws_instance" "web" {
# ... other configurations
key_name = aws_key_pair.deployer.key_name
# ... other configurations
}
Executing Commands with User Data
user_data allows you to pass shell scripts or cloud-init directives to an instance at launch time. This is useful for bootstrapping, installing software, or configuring services.
resource "aws_instance" "web" {
# ... other configurations
key_name = aws_key_pair.deployer.key_name
user_data = <<EOF
#!/bin/bash
sudo apt update -y
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
echo "Hello from Terraform provisioned EC2!" | sudo tee /var/www/html/index.html
EOF
# ... other configurations
}
The <<EOF ... EOF syntax is a "heredoc" in HCL (HashiCorp Configuration Language), allowing multi-line strings. Note that user_data scripts run only once during the instance's initial launch.
Terraform Lifecycle Commands
Once your .tf files are complete, you'll use the Terraform CLI to manage your infrastructure.
terraform init: Initializes the working directory, downloads provider plugins (likehashicorp/aws), and sets up the backend for state management. This must be run for a new or updated configuration.terraform plan: Generates an execution plan. It shows what actions Terraform will take (create, modify, destroy) without actually performing them. This is crucial for reviewing changes before applying.terraform apply: Executes the actions proposed in a plan. It will prompt for confirmation unless-auto-approveis used (use with caution, especially in production).terraform destroy: Tears down all resources managed by the current Terraform configuration. This should also be reviewed carefully viaterraform plan -destroyfirst.
# Initialize the working directory
terraform init
# Review the proposed changes
terraform plan
# Apply the changes to provision the EC2 instance
terraform apply
# To clean up all resources
# terraform destroy
Common Pitfalls
- AMI ID Mismatch: Using an AMI ID from one region in another will fail. Always verify the AMI ID for your target region.
- Security Group Rules: Overly permissive security groups (e.g., SSH from
0.0.0.0/0) are a significant security risk. Be as specific as possible with CIDR blocks. Conversely, overly restrictive rules can lead to connectivity issues. - Key Pair Permissions: Ensure your private key file (
~/.ssh/my-ec2-key) has correct permissions (e.g.,chmod 400 ~/.ssh/my-ec2-key) to prevent SSH connection failures. - State File Management: The
terraform.tfstatefile tracks the state of your infrastructure. In a team environment, this file must be stored remotely (e.g., S3 bucket with DynamoDB locking) to prevent conflicts and ensure consistency. - Resource Dependencies: Terraform automatically infers most dependencies, but explicit
depends_onarguments might be needed for complex or non-obvious relationships, though this is rare for basic EC2 provisioning. - Provider Version Drift: Pinning provider versions (e.g.,
~> 5.0) is good, but be aware that major version upgrades (e.g., 5.x to 6.x) often introduce breaking changes that require configuration updates.