Provision an AWS VPC, EC2, and S3 Bucket with Terraform From Scratch
Stand up a production-shaped AWS environment — custom VPC, SSH-accessible EC2 instance, and a versioned private S3 bucket — in ~100 lines of Terraform you can destroy just as fast.
What You'll Build
A complete, destroyable AWS environment: a custom VPC with a public subnet, an Amazon Linux 2023 EC2 instance you can SSH into, and a versioned private S3 bucket — all defined in ~100 lines of Terraform you can recreate in minutes.
Prerequisites
- Terraform ≥ 1.5 — install via tfenv or
brew install terraformon macOS - AWS CLI v2 — a configured profile with EC2, VPC, and S3 permissions (
aws configure) - An ED25519 SSH keypair on disk:
ssh-keygen -t ed25519 -f ~/.ssh/tf_demo - Examples target
us-east-1; adjust freely viaterraform.tfvars
1. Project Layout
mkdir terraform-aws && cd terraform-aws
touch main.tf variables.tf outputs.tf terraform.tfvars
All resources live in main.tf. Splitting into per-service files (vpc.tf, ec2.tf, etc.) is idiomatic at scale but unnecessary here.
2. Provider Configuration
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.5"
}
provider "aws" {
region = var.aws_region
}
3. Variables
variables.tf:
variable "aws_region" { default = "us-east-1" }
variable "vpc_cidr" { default = "10.0.0.0/16" }
variable "subnet_cidr" { default = "10.0.1.0/24" }
variable "instance_type" { default = "t3.micro" }
variable "public_key_path" { default = "~/.ssh/tf_demo.pub" }
variable "allowed_ssh_cidr" {
default = "203.0.113.5/32"
description = "Your workstation IP in CIDR notation. Never use 0.0.0.0/0 in production."
}
terraform.tfvars:
allowed_ssh_cidr = "203.0.113.5/32" # replace with: $(curl -s ifconfig.me)/32
4. VPC and Networking
Append to main.tf:
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = { Name = "tf-demo-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = { Name = "tf-demo-public" }
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = { Name = "tf-demo-igw" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = { Name = "tf-demo-rt" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
5. Security Group and EC2
Append to main.tf:
resource "aws_security_group" "ec2_sg" {
name = "tf-demo-sg"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.allowed_ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "tf-demo-sg" }
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_key_pair" "demo" {
key_name = "tf-demo-key"
public_key = file(pathexpand(var.public_key_path))
}
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
key_name = aws_key_pair.demo.key_name
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
tags = { Name = "tf-demo-ec2" }
}
Two decisions worth noting: data "aws_ami" always resolves to the latest Amazon-published AL2023 image, avoiding hardcoded AMI IDs that differ per region and rot over time. vpc_security_group_ids (not security_groups) is required for non-default VPCs.
6. S3 Bucket
AWS provider v5 splits bucket configuration into discrete resources — the old acl = "private" on the bucket resource is deprecated:
Append to main.tf:
resource "aws_s3_bucket" "data" {
bucket_prefix = "tf-demo-"
tags = { Name = "tf-demo-bucket" }
}
resource "aws_s3_bucket_ownership_controls" "data" {
bucket = aws_s3_bucket.data.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
bucket_prefix generates a globally unique name (prefix + random suffix), sidestepping BucketAlreadyExists conflicts. BucketOwnerEnforced disables ACLs entirely — the correct default for new buckets.
7. Outputs
outputs.tf:
output "instance_public_ip" {
value = aws_instance.web.public_ip
}
output "s3_bucket_name" {
value = aws_s3_bucket.data.bucket
}
output "vpc_id" {
value = aws_vpc.main.id
}
8. Init, Plan, Apply
terraform init # downloads hashicorp/aws provider (~50 MB)
terraform fmt # normalises HCL formatting in-place
terraform validate # catches syntax and type errors before touching AWS
terraform plan -out=tfplan
terraform apply tfplan
plan -out saves the exact diff so apply executes precisely what you reviewed. Expect 15–20 resources created.
Verify It Works
All verification commands below run on your local workstation, where the AWS CLI and Terraform are installed and configured.
Verify EC2 — SSH in from your workstation:
# ec2-user is the default user on Amazon Linux 2023
ssh -i ~/.ssh/tf_demo ec2-user@"$(terraform output -raw instance_public_ip)"
Successful SSH drops you at an AL2023 shell prompt. Type exit to return to your local shell before running the next check.
Verify S3 — run this from your local workstation (not inside the SSH session):
# Exit the SSH session first if you are still connected: type 'exit'
aws s3 ls "$(terraform output -raw s3_bucket_name)"
aws s3 ls returns an empty listing (no error) for a new empty bucket. This command requires both the AWS CLI and your local AWS credentials — it will not work from inside the EC2 instance, which has neither pre-installed in this tutorial.
Tear down every resource:
terraform destroy
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
no EC2 AMI found in data.aws_ami |
Filter matches nothing in the chosen region | Run aws ec2 describe-images --owners amazon --filters "Name=name,Values=al2023-ami-*-x86_64" --query "Images[0].Name" --region us-east-1 to validate the filter |
InvalidKeyPair.NotFound |
Key pair deleted out-of-band or profile region mismatch | Ensure var.aws_region matches your CLI profile region; run terraform destroy then terraform apply to recreate |
SSH Connection refused or timeout |
allowed_ssh_cidr doesn't match your current public IP |
Update terraform.tfvars with $(curl -s ifconfig.me)/32 and re-run terraform apply |
UnauthorizedOperation on VPC/EC2/S3 |
IAM principal lacks the required permissions | Attach least-privilege IAM policies for EC2, VPC, and S3 to your user or role |
Next Steps
- Remote state: Move
terraform.tfstateto S3 with DynamoDB locking via abackend "s3"block — essential before any team use. - Modules: Refactor the VPC block with terraform-aws-modules/vpc for multi-AZ coverage, NAT gateways, and private subnets.
- Secrets: Pass sensitive variables via
TF_VAR_*environment variables or integrateaws_secretsmanager_secret— never commit.tfvarsfiles containing secrets. - Drift detection: Run
terraform planon a CI schedule to catch out-of-band changes made through the AWS console.
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.