Skip to main content

Terraform AWS Project for Beginners: S3 and EC2

Provision a private S3 bucket, security group and EC2 web server with Terraform, learn plan, apply and state, then keep the repo as proof.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project provisions a private S3 bucket, a security group and an EC2 web server on AWS with Terraform, using the plan, apply, state and destroy workflow that real teams use. You finish with a small Git repository that recreates the same infrastructure on demand and removes it cleanly, which is the core skill behind every Terraform role.

The problem Terraform solves

Clicking through the AWS console leaves no record of what you built. Nobody can tell which settings were chosen or why, and building the same setup a second time means repeating every click and hoping you match the original. Environments drift apart, and when something breaks, the only way to reconstruct it is guesswork.

Infrastructure as Code (IaC) fixes this by describing infrastructure in text files. Teams at companies like Shopify and Atlassian keep those files in Git, review changes in pull requests and apply them the same way in every environment. Terraform is the most widely used IaC tool: you describe the end state you want, and Terraform works out the steps to get there.

What you will build

flowchart TD A["Your .tf files"] --> B["terraform plan"] B --> C["terraform apply"] C --> D["S3 bucket"] C --> E["Security group"] E --> F["EC2 web server"] C --> G["terraform.tfstate"] classDef code fill:#475569,color:#FFFFFF classDef aws fill:#F97316,color:#FFFFFF classDef state fill:#8B5CF6,color:#FFFFFF class A,B,C code class D,E,F aws class G state

You create three kinds of AWS resource, all in eu-west-1:

  • A private S3 bucket with versioning, encryption and public access blocked
  • A security group that allows web traffic only from your own IP address
  • A small EC2 instance running Nginx, started with a boot script

Terraform records everything it creates in a state file. That file is how Terraform knows what already exists, which is what makes later changes and deletions safe.

What you will have at the end

  • A Git repository with versions.tf, variables.tf, s3.tf, ec2.tf and outputs.tf
  • Saved terminal output of a clean plan, apply and destroy, which makes a strong README for a portfolio
  • A working mental model of providers, resources, data sources, variables, outputs and state

Prerequisites and cost

You need an AWS account, a terminal (Linux, macOS or WSL) and Git. The resources used here are small and usually cost pennies for a short session, but anything left running keeps billing, so finish the project by running terraform destroy. Set an AWS Budgets alert before you start.

Milestone 1: Install Terraform and Connect to AWS

This milestone installs Terraform, gives it credentials for your AWS account and confirms both work. Terraform calls the AWS API on your behalf, so it needs the same identity you would use with the AWS CLI.

Terraform versions change often, and different projects can need different ones. A version manager such as tfenv lets you install and switch between versions per project.

Bash
# Install tfenv, then a specific Terraform version
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
export PATH="$HOME/.tfenv/bin:$PATH"
tfenv install 1.15.5
tfenv use 1.15.5
terraform version
# Expected: Terraform v1.15.5
Tip

On macOS you can run brew install tfenv instead. If a newer Terraform 1.x is available when you read this, use it. The code in this project needs 1.10 or later.

Next, give the AWS CLI credentials. The simplest route for a learning account is a dedicated IAM user for Terraform.

  • In the IAM console, create a user such as terraform-learner and attach the AdministratorAccess policy
  • Create an access key for the command line and run aws configure
  • Use eu-west-1 as the default region and json as the output format
Bash
aws configure
# AWS Access Key ID: <your key>
# AWS Secret Access Key: <your secret>
# Default region name: eu-west-1
# Default output format: json
aws sts get-caller-identity
# Expected: JSON with your 12-digit Account ID and the terraform-learner user ARN
Security

Broad admin rights are acceptable in a throwaway learning account only. Never use the root user, never commit access keys to Git, and in a real job use short-lived credentials (AWS IAM Identity Center with aws configure sso) and a policy limited to what Terraform actually manages.

Common Mistake

Running aws configure in one terminal profile and terraform in another shell that has different AWS_PROFILE or AWS_ACCESS_KEY_ID variables set. Terraform then runs against the wrong account. Always run aws sts get-caller-identity in the same terminal before terraform apply.

Milestone 2: Write the Provider and Variables

This milestone creates the project folder and the two files every Terraform project starts with: one that says which cloud to talk to, and one that defines the inputs.

A provider is a plugin that teaches Terraform how to talk to a platform; the AWS provider turns your resource blocks into AWS API calls. Variables are inputs, much like function parameters, so the same code can run with different values.

Bash
mkdir terraform-aws-basics && cd terraform-aws-basics
git init

Create versions.tf:

HCL
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
# Every resource that supports tags gets these automatically
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}

The ~> 6.0 constraint accepts any 6.x release but not 7.0, so a major upgrade can never surprise you. Create variables.tf:

HCL
variable "region" {
description = "AWS region to deploy into"
type = string
default = "eu-west-1"
}
variable "project_name" {
description = "Name prefix for every resource"
type = string
default = "devops-learning"
}
variable "environment" {
description = "Deployment environment"
type = string
default = "development"
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "environment must be development, staging or production."
}
}
variable "instance_type" {
description = "EC2 instance type (pick a free-tier-eligible type for your account)"
type = string
default = "t3.micro"
}
variable "my_ip" {
description = "Your public IPv4 address, used to restrict web access"
type = string
validation {
condition = can(cidrhost("${var.my_ip}/32", 0))
error_message = "my_ip must be a valid IPv4 address such as 203.0.113.10."
}
}

Create .gitignore before you run anything, because Terraform writes files you must never commit:

Bash
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
terraform.tfvars
Tip

Do commit .terraform.lock.hcl, which Terraform creates on init. It pins the exact provider version so your teammates and CI get identical downloads.

Common Mistake

Committing terraform.tfstate or .terraform/ to Git. State can contain sensitive values and the provider binaries are over 100 MB. Add them to .gitignore first, then commit.

Milestone 3: Define the S3 Bucket

This milestone adds a private, versioned and encrypted S3 bucket, plus a data source that makes the bucket name globally unique without hardcoding anything.

A resource block creates and manages a thing, written as resource "TYPE" "LOCAL_NAME". The type comes from the provider; the local name is only a handle used inside your code. A data source reads information that already exists, such as your account ID. S3 bucket names must be unique across all AWS accounts, so the account ID in the name avoids collisions.

Create s3.tf:

HCL
data "aws_caller_identity" "current" {}
locals {
name_prefix = "${var.project_name}-${var.environment}"
}
resource "aws_s3_bucket" "main" {
bucket = "${local.name_prefix}-${data.aws_caller_identity.current.account_id}"
# Lab only: lets terraform destroy remove a bucket that still has files in it
force_destroy = true
}
# Keep old versions of a file when it is overwritten or deleted
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = "Enabled"
}
}
# Block every form of public access
resource "aws_s3_bucket_public_access_block" "main" {
bucket = aws_s3_bucket.main.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Encrypt objects at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}

Notice that aws_s3_bucket_versioning.main refers to aws_s3_bucket.main.id. That reference tells Terraform to create the bucket first. Terraform builds a dependency graph from these references, so you never write the order yourself.

Tip

New S3 buckets already block public access and encrypt with SSE-S3 by default. Declaring both in code still matters, because it documents the intent, protects you if defaults change, and lets you switch to a KMS key later.

Common Mistake

Hardcoding a bucket name like my-files and getting BucketAlreadyExists on apply. The name must be unique worldwide, so always build it from something unique to you, such as the account ID.

Milestone 4: Define the Security Group and EC2 Server

This milestone adds a security group that only lets your IP reach the web server, then launches an Ubuntu EC2 instance that installs Nginx on first boot.

A security group is a virtual firewall attached to an instance. It denies inbound traffic unless a rule allows it, which is why a rule limited to your_ip/32 keeps a demo server off the public internet. This project uses your account's default VPC so you can focus on Terraform itself.

Create ec2.tf:

HCL
# Use the default VPC that exists in most AWS accounts
data "aws_vpc" "default" {
default = true
}
# Look up the newest Ubuntu 24.04 image instead of hardcoding an AMI ID
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical, the publisher of Ubuntu
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
description = "Allow HTTP from my IP only"
vpc_id = data.aws_vpc.default.id
}
resource "aws_vpc_security_group_ingress_rule" "http_from_me" {
security_group_id = aws_security_group.web.id
description = "HTTP from my IP"
cidr_ipv4 = "${var.my_ip}/32"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "all_outbound" {
security_group_id = aws_security_group.web.id
description = "Allow all outbound traffic so the server can install packages"
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
associate_public_ip_address = true
# Require IMDSv2, which blocks a common credential-theft technique
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
root_block_device {
volume_size = 8
volume_type = "gp3"
encrypted = true
}
# Runs once at first boot
user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
echo '<h1>Provisioned by Terraform</h1>' > /var/www/html/index.html
systemctl enable --now nginx
EOF
# Changing user_data replaces the instance so the new script actually runs
user_data_replace_on_change = true
tags = {
Name = "${local.name_prefix}-web"
}
}

Create outputs.tf. Outputs are the values Terraform prints after an apply, and other tools can read them:

HCL
output "s3_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.main.bucket
}
output "ec2_instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "ec2_public_ip" {
description = "Public IP address of the web server"
value = aws_instance.web.public_ip
}
output "website_url" {
description = "URL of the Nginx test page"
value = "http://${aws_instance.web.public_ip}"
}
Security

Opening port 80 to 0.0.0.0/0 is a common beginner shortcut that exposes a test server to the whole internet. The my_ip variable avoids that, and the validation block stops a typo from slipping through.

Common Mistake

Getting No default VPC or a subnet error on apply. Some accounts have deleted their default VPC. Run aws ec2 create-default-vpc to recreate it, or point the security group and instance at a VPC and subnet you created.

Milestone 5: Run init, plan and apply

This milestone runs the Terraform workflow end to end: download the provider, preview the changes, then create the real resources.

Terraform separates "what will change" from "make the change" so you can review before anything happens. Always read the plan. It is your last chance to catch a wrong region, a surprising delete or a missing rule.

Command What it does
terraform init Downloads the provider and prepares the folder. Run once per project
terraform fmt Rewrites files into the standard style
terraform validate Checks syntax and references without calling AWS
terraform plan Shows what would be created, changed or destroyed
terraform apply Makes the changes after you confirm
terraform destroy Deletes everything Terraform manages
Bash
# Give Terraform your values (this file is in .gitignore)
cat > terraform.tfvars <<EOF
project_name = "devops-learning"
environment = "development"
my_ip = "$(curl -s https://checkip.amazonaws.com | tr -d '\n')"
EOF
terraform init
# Expected: Terraform has been successfully initialized!
terraform fmt
terraform validate
# Expected: Success! The configuration is valid.
terraform plan
# Expected: Plan: 8 to add, 0 to change, 0 to destroy.
terraform apply
# Type yes when prompted
# Expected: Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
terraform output
# Expected: ec2_instance_id, ec2_public_ip, s3_bucket_name and website_url

The 8 resources are the bucket, its three settings (versioning, public access block, encryption), the security group, its two rules and the instance. Data sources are read, not created, so they do not count.

Tip

Use terraform plan -out=tfplan followed by terraform apply tfplan in teams and CI. It guarantees that what was reviewed is exactly what gets applied.

Common Mistake

Typing yes to apply without reading the plan. Make a habit of scanning for destroy and must be replaced lines first, because a small edit to one attribute can force Terraform to delete and recreate a resource.

Milestone 6: Inspect State and Change Infrastructure

This milestone shows how Terraform remembers what it created, then makes two changes to see the difference between an in-place update and a replacement.

After apply, Terraform writes terraform.tfstate, a JSON file mapping each resource in your code to a real AWS object. On the next plan, Terraform compares your code, the state and the real world, and proposes only the difference. That is why running apply twice is safe.

Bash
terraform state list
# Expected: data.aws_ami.ubuntu, aws_instance.web, aws_s3_bucket.main,
# aws_security_group.web and the other resources
terraform state show aws_instance.web
# Expected: every attribute Terraform knows about the instance

Make an in-place change first. Edit ec2.tf and add a tag to the instance:

HCL
tags = {
Name = "${local.name_prefix}-web"
Team = "platform"
}
Bash
terraform plan
# Expected: Plan: 0 to add, 1 to change, 0 to destroy.
# The ~ symbol means update in place
terraform apply

Now force a replacement. Change the heading text inside user_data from Provisioned by Terraform to Updated by Terraform:

Bash
terraform plan
# Expected: Plan: 1 to add, 0 to change, 1 to destroy.
# The instance line shows -/+ and "must be replaced"
terraform apply

Some attributes cannot be changed on a running resource, so Terraform destroys and recreates it. The -/+ marker in the plan is how you spot this before it happens.

Security

Treat the state file like a password file. It can store secrets in plain text, which is why teams keep state in an encrypted, access-controlled remote backend such as S3 with locking instead of on a laptop.

Common Mistake

Editing terraform.tfstate by hand or deleting it to "start fresh". Terraform then forgets the resources exist and tries to create duplicates. Use terraform state subcommands, or terraform destroy, instead.

Validation & Testing

This final step proves the infrastructure works, behaves safely and can be removed cleanly. Run these checks in order.

Bash
# 1. Read the outputs
terraform output
BUCKET=$(terraform output -raw s3_bucket_name)
INSTANCE_ID=$(terraform output -raw ec2_instance_id)
URL=$(terraform output -raw website_url)
# 2. The web server answers (allow 1 to 2 minutes after apply for Nginx to install)
curl -s --max-time 10 "$URL"
# Expected: <h1>Updated by Terraform</h1> (or your latest text)
# 3. The bucket is encrypted, versioned and private
aws s3api get-bucket-encryption --bucket "$BUCKET" \
--query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm'
# Expected: "AES256"
aws s3api get-bucket-versioning --bucket "$BUCKET" --query Status
# Expected: "Enabled"
aws s3api get-public-access-block --bucket "$BUCKET" \
--query 'PublicAccessBlockConfiguration'
# Expected: all four settings are true
# 4. The instance enforces IMDSv2
aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].MetadataOptions.HttpTokens'
# Expected: "required"
# 5. Idempotency: with nothing changed, a plan finds no work
terraform plan -detailed-exitcode
echo "exit code: $?"
# Expected: No changes. Your infrastructure matches the configuration.
# Exit code 0 means no changes, 2 means changes are pending

Try one negative test. From a different network (a phone hotspot works), run curl --max-time 5 "$URL". It should time out, because the security group only allows your original IP.

Clean up

Destroy everything so nothing keeps billing:

Bash
terraform plan -destroy
# Review what will be deleted
terraform destroy
# Type yes to confirm
# Expected: Destroy complete! Resources: 8 destroyed.
# Confirm no instance is left running
aws ec2 describe-instances \
--filters "Name=tag:Project,Values=devops-learning" \
"Name=instance-state-name,Values=pending,running,stopping,stopped" \
--query 'Reservations[].Instances[].InstanceId'
# Expected: []

Commit your code and a short README.md containing the plan and apply output. That repository is your proof of work.

Where to go next

  • Move state to an S3 backend with native locking (use_lockfile = true)
  • Replace the default VPC with a VPC you define in code
  • Split the code into a reusable module and deploy staging and production from it
Found a mistake, an outdated step or a broken link?