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
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.tfandoutputs.tf - Saved terminal output of a clean
plan,applyanddestroy, 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.
# Install tfenv, then a specific Terraform versiongit clone https://github.com/tfutils/tfenv.git ~/.tfenvecho 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrcexport PATH="$HOME/.tfenv/bin:$PATH" tfenv install 1.15.5tfenv use 1.15.5terraform version# Expected: Terraform v1.15.5TipOn macOS you can run
brew install tfenvinstead. 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-learnerand attach theAdministratorAccesspolicy - Create an access key for the command line and run
aws configure - Use
eu-west-1as the default region andjsonas the output format
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 ARNSecurityBroad 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 MistakeRunning
aws configurein one terminal profile andterraformin another shell that has differentAWS_PROFILEorAWS_ACCESS_KEY_IDvariables set. Terraform then runs against the wrong account. Always runaws sts get-caller-identityin the same terminal beforeterraform 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.
mkdir terraform-aws-basics && cd terraform-aws-basicsgit initCreate versions.tf:
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:
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:
.terraform/*.tfstate*.tfstate.**.tfplanterraform.tfvarsTipDo commit
.terraform.lock.hcl, which Terraform creates oninit. It pins the exact provider version so your teammates and CI get identical downloads.
Common MistakeCommitting
terraform.tfstateor.terraform/to Git. State can contain sensitive values and the provider binaries are over 100 MB. Add them to.gitignorefirst, 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:
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 deletedresource "aws_s3_bucket_versioning" "main" { bucket = aws_s3_bucket.main.id versioning_configuration { status = "Enabled" }} # Block every form of public accessresource "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 restresource "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.
TipNew 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 MistakeHardcoding a bucket name like
my-filesand gettingBucketAlreadyExistson 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:
# Use the default VPC that exists in most AWS accountsdata "aws_vpc" "default" { default = true} # Look up the newest Ubuntu 24.04 image instead of hardcoding an AMI IDdata "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:
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}"}SecurityOpening port 80 to
0.0.0.0/0is a common beginner shortcut that exposes a test server to the whole internet. Themy_ipvariable avoids that, and the validation block stops a typo from slipping through.
Common MistakeGetting
No default VPCor a subnet error on apply. Some accounts have deleted their default VPC. Runaws ec2 create-default-vpcto 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 |
# Give Terraform your values (this file is in .gitignore)cat > terraform.tfvars <<EOFproject_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 fmtterraform 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_urlThe 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.
TipUse
terraform plan -out=tfplanfollowed byterraform apply tfplanin teams and CI. It guarantees that what was reviewed is exactly what gets applied.
Common MistakeTyping
yestoapplywithout reading the plan. Make a habit of scanning fordestroyandmust be replacedlines 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.
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 instanceMake an in-place change first. Edit ec2.tf and add a tag to the instance:
tags = { Name = "${local.name_prefix}-web" Team = "platform" }terraform plan# Expected: Plan: 0 to add, 1 to change, 0 to destroy.# The ~ symbol means update in place terraform applyNow force a replacement. Change the heading text inside user_data from Provisioned by Terraform to Updated by Terraform:
terraform plan# Expected: Plan: 1 to add, 0 to change, 1 to destroy.# The instance line shows -/+ and "must be replaced" terraform applySome 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.
SecurityTreat 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 MistakeEditing
terraform.tfstateby hand or deleting it to "start fresh". Terraform then forgets the resources exist and tries to create duplicates. Useterraform statesubcommands, orterraform destroy, instead.
Validation & Testing
This final step proves the infrastructure works, behaves safely and can be removed cleanly. Run these checks in order.
# 1. Read the outputsterraform outputBUCKET=$(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 privateaws 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 IMDSv2aws 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 workterraform plan -detailed-exitcodeecho "exit code: $?"# Expected: No changes. Your infrastructure matches the configuration.# Exit code 0 means no changes, 2 means changes are pendingTry 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:
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 runningaws 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
stagingandproductionfrom it