Terraform: first project

Reading about IaC is step one; step two is a small, real terraform apply in a sandbox AWS account. You will create a VPC with one public subnet, an S3 bucket for logs, and walk through init, plan, apply, and destroy with local state.

Prerequisites: AWS account (free tier or sandbox), ability to create access keys or SSO profile, and Terraform 1.6+ installed.

After reading, you should be able to:

Cost & safety: Use a dedicated sandbox account or sub-account. Run terraform destroy when finished. This lab creates billable resources (VPC, NAT-free public subnet only, S3).

Step 0 — Install tools

# macOS (Homebrew)
brew install terraform awscli

# Verify
terraform version    # >= 1.6
aws --version
aws sts get-caller-identity   # confirms credentials

Linux: use HashiCorp’s APT/Yum repo for Terraform; install awscli v2 from AWS docs. Windows: winget install Hashicorp.Terraform and AWS CLI v2 MSI.

Terraform project file layout and resources created: VPC, subnet, S3.
One directory, four Terraform files, local state for learning—remote backends come in the next guide.

Step 1 — AWS credentials (sandbox only)

aws configure --profile tf-sandbox
# enter access key, secret, region e.g. us-east-1

export AWS_PROFILE=tf-sandbox
export AWS_REGION=us-east-1

Attach a policy that allows VPC and S3 management in the sandbox (for learning, PowerUserAccess in a throwaway account is common; tighten in production).

Step 2 — Create the project directory

mkdir -p ~/tf-sandbox && cd ~/tf-sandbox

Create .gitignore before first init:

.terraform/
*.tfstate
*.tfstate.*
.terraform.lock.hcl
crash.log
*.tfvars
!example.tfvars

Step 3 — Provider and variables

versions.tf

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variables.tf

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "project" {
  type    = string
  default = "sharpbyte-lab"
}

variable "environment" {
  type    = string
  default = "sandbox"
}

Step 4 — VPC and public subnet

main.tf — networking block (EKS-ready shape starts here; cluster comes later):

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = {
    Name = "${var.project}-${var.environment}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "${var.project}-${var.environment}-igw"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true
  tags = {
    Name = "${var.project}-${var.environment}-public-a"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
  tags = {
    Name = "${var.project}-${var.environment}-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

4.1 — S3 bucket for application logs

Append to main.tf (globally unique bucket name required):

resource "aws_s3_bucket" "logs" {
  bucket = "${var.project}-${var.environment}-logs-${data.aws_caller_identity.current.account_id}"
  tags = {
    Name        = "${var.project}-logs"
    Environment = var.environment
  }
}

resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

data "aws_caller_identity" "current" {}

Move data "aws_caller_identity" "current" to the top of main.tf if you prefer data sources grouped together.

Step 5 — Outputs

# outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_id" {
  value = aws_subnet.public.id
}

output "logs_bucket_name" {
  value = aws_s3_bucket.logs.id
}

Step 6 — init, plan, apply

cd ~/tf-sandbox
terraform init

Downloads the AWS provider into .terraform/ and creates .terraform.lock.hcl (commit the lock file; ignore .terraform/).

terraform fmt -recursive
terraform validate
terraform plan

Expect roughly 10+ resources to add (VPC, IGW, subnet, routes, S3, versioning, encryption). Read every + line before continuing.

terraform apply

Type yes when prompted (or use -auto-approve only in sandboxes). Apply takes one to three minutes.

terraform output
aws s3 ls | grep sharpbyte-lab
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=sharpbyte-lab-sandbox-vpc"

Step 7 — Inspect state (local for now)

ls -la terraform.tfstate
terraform state list
terraform state show aws_vpc.main

The state file maps aws_vpc.main to your real VPC ID. Next guide moves this to S3 with locking—do not commit state to a public repo.

Step 8 — Change and re-plan

Add a tag in aws_vpc.main:

tags = {
  Name    = "${var.project}-${var.environment}-vpc"
  Lab     = "terraform-first-project"
}
terraform plan   # should show ~ update in-place only on tags

Small in-place updates are safe; watch for -/+ which means replace (new physical ID).

Step 9 — Destroy when done

terraform destroy

S3 buckets must be empty before destroy if you added objects—either delete objects in the console or add a force-destroy flag only in sandboxes:

resource "aws_s3_bucket" "logs" {
  # ...
  force_destroy = true   # sandbox only
}

Step 10 — Troubleshooting

ErrorFix
No valid credential sourcesSet AWS_PROFILE or env vars; run aws sts get-caller-identity
AccessDenied on ec2:CreateVpcIAM policy missing VPC permissions
BucketAlreadyExistsS3 names are global—include account ID in bucket name as above
Error acquiring the state lockStale lock from interrupted apply—next guide covers DynamoDB locks
Provider version mismatchRun terraform init -upgrade and commit lock file

Interview phrase: “I keep Terraform in version control with lock files, run plan in PRs, use variables for environment differences, and never apply networking changes to prod without reviewing destroys—state belongs in remote storage with locking for teams.”

The one line to remember

Your first Terraform project is init → plan → apply → verify → destroy in a sandbox—prove you can read the plan before you trust apply in production.