Replace the cloud console with code. Then teach the code to share itself.
A working URL on a t3.micro. The deploy is three commands. Caddy handles TLS automatically.
The VM itself took 18 clicks in the AWS console to bring up. If it dies — or if a teammate wants their own copy — we re-do the 18 clicks. That’s a pet, not infrastructure.
“If you can’t describe it as code, you can’t repeat it reliably.”
.tf files and talks to cloud APIs..tf) and actual (real cloud), then applies the minimum delta — same shape as docker compose up -d.terraform.tfstate). That state must be shared between teammates — hence “remote state.”main.tf # resources: the VM, security group, key pair, EIP
variables.tf # input knobs: region, instance_type, ssh_key_name
outputs.tf # what to print after apply: public IP, ssh command
versions.tf # pin Terraform + provider versions
That’s the whole project for a single VM — roughly 80 lines of HCL. Add user_data.sh (the first-boot script) and terraform/backend/ for the remote-state bootstrap and you have the complete Module 3 Terraform tree.
versions.tf)terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = var.region
}
terraform init downloads the provider plugin to .terraform/.~> 5.0 means “any 5.x release” — gets patch fixes, avoids 6.x surprises.main.tf)resource "aws_instance" "vm" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = var.ssh_key_name
vpc_security_group_ids = [aws_security_group.web.id]
user_data = local.user_data
tags = { Name = "url-shortener" }
}
One resource declaration ≈ one click in the EC2 console. user_data runs the Docker install script from L07 automatically on first boot — by the time apply returns the IP, the stack is already running.
terraform init # download providers (once per checkout)
terraform plan # show diff — no changes applied
terraform apply # apply the diff after confirmation
terraform destroy # remove everything you created
init is one-time per project. Re-run after editing versions.tf or the backend block.plan shows + create, - destroy, ~ in-place change, -/+ replace.apply runs plan again so the diff you approve is the diff you get.destroy is the cattle escape hatch: tear down, recreate from code.terraform.tfstate maps your .tf resource names to real cloud IDs (aws_instance.vm → i-0abc123). Without it, every plan would try to create everything from scratch.
terraform.tfstate next to your .tf files is fine — if you don’t lose your laptop.apply, you need remote state.*.tfstate* to .gitignore.terraform {
backend "s3" {
bucket = "url-shortener-tfstate-acme"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "url-shortener-tflock"
encrypt = true
}
}
applys from corrupting state simultaneously.The S3 bucket and DynamoDB table can’t live in the same state — state has to already exist for that to work. Two clean solutions:
Three CLI commands once. Fast, but not reproducible across environments without docs.
A tiny separate Terraform project with local state creates the bucket + table. Reproducible per-environment. This is terraform/backend/.
cd cloud-devops/sample-app/terraform/backend
terraform init && terraform apply -auto-approve # creates bucket + table
cd ..
# Edit versions.tf — uncomment the backend "s3" { ... } block
terraform init -migrate-state # uploads local tfstate to S3
# Confirm 'yes'; the local file becomes a stub
After migration: wipe your laptop, clone the repo, run terraform init + plan from any machine with AWS credentials — same diff, same state.
sample-app/terraform/backend/ — bootstrap module; creates the S3 bucket (versioned, encrypted, public-access-blocked) + DynamoDB lock table.sample-app/terraform/ — main module: AMI lookup, security group (22/80/443), t3.micro, Elastic IP, user_data.sh that installs Docker and starts the L07 compose stack on first boot..env still lives on the VM disk (same as L07); AWS Secrets Manager lands in Module 4.destroy wipes the data. Module 4 moves it to managed RDS.terraform apply rhythm stays; what it provisions changes from a single EC2 instance to an EKS cluster.