Skip to content
Cloud & Infra Advanced Tutorial

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.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jun 11, 2026 · 8 min read

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 terraform on 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 via terraform.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:

Advertisement

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.tfstate to S3 with DynamoDB locking via a backend "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 integrate aws_secretsmanager_secret — never commit .tfvars files containing secrets.
  • Drift detection: Run terraform plan on a CI schedule to catch out-of-band changes made through the AWS console.
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading