Provisioning a Linux server on AWS using Terraform. Instead of clicking through the AWS Console to create a server, this Terraform config does it all automatically in under 60 seconds.
What it does:
- Creates an SSH key pair on AWS (so you can log in)
- Creates a security group (firewall allowing SSH + HTTP)
- Spins up a t3.micro EC2 instance (Ubuntu 24.04 Free Tier)
- Prints the server's IP and a ready-to-run SSH command
Tech stack: Terraform · AWS EC2 · Ubuntu Linux
terraform-project-2/
├── README.md ← You are here
├── main.tf ← The main infrastructure (provider, key, security group, EC2)
├── variables.tf ← Input variables (region, AMI, instance type, key path)
├── outputs.tf ← Values printed after apply (IP address, SSH command)
├── .gitignore ← Keeps state files and secrets out of GitHub
└── alternate/
└── main-modern-sg-style.tf.example ← Reference-only alt. security group pattern
Before you start, you need three things:
Sign up at aws.amazon.com if you don't have one. Everything in this project is free tier eligible if you terminate the instance when done.
Mac:
$ brew tap hashicorp/tap
$ brew install hashicorp/tap/terraform
$ terraform -v # should print a version numberMac:
$ brew install awscliThen configure it with your AWS credentials:
$ aws configureYou'll be prompted for:
AWS Access Key ID: ← from AWS Console → IAM → Users → Security credentials → Create access key
AWS Secret Access Key: ← same place
Default region name: us-east-1 (pick based on your zone)
Default output format: json
To create an access key: AWS Console → IAM → Users → your username → Security credentials tab → Create access key → choose "CLI" → download the CSV.
Check if you already have a key:
$ ls ~/.ssh/id_rsa.pubIf you see the file, skip to Part 2.
If not, create one:
$ ssh-keygen -t rsa -b 4096 -C "your@email.com"
# Press Enter through all prompts to use the default location (~/.ssh/id_rsa)This creates two files:
~/.ssh/id_rsa— your private key (never share this)~/.ssh/id_rsa.pub— your public key (this gets uploaded to AWS)
$ git clone https://github.com/bysaania/terraform-project-2.git
$ cd terraform-project-2$ terraform initThis downloads the AWS provider plugin (a binary that lets Terraform talk to AWS). You only need to run this once per project.
Expected output:
% terraform init
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.51.0...
- Installed hashicorp/aws v6.51.0 (signed by HashiCorp)
Initializing the backend...
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
To make sure that all your .tf files are neatly formatted with proper indentation
$ terraform fmtThen run the following to validate before you run plan
$ terraform validate
Success! The configuration is valid.$ terraform planThis is a dry run — it shows you exactly what Terraform would create without actually doing anything. Read through the output before applying.
Expected output (abbreviated):
$ terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0b6d9d3d33ba97d99"..
+ instance_type = "t3.micro"
+ key_name = "terraform-project-2-key"
+ region = "us-east-1"
...
+ tags = {
+ "Name" = "terraform-project-2"
+ "Project" = "terraform-project-2"
}
}
# aws_key_pair.terraform_key will be created
+ resource "aws_key_pair" "terraform_key" {
+ key_name = "terraform-project-2-key"
+ public_key = "ssh-rsa ....l5lQyhWu05OioNy.......== terraform-project-2"
+ region = "us-east-1"
+ tags = {
+ "Name" = "terraform-project-2-key"
+ "Project" = "terraform-project-2"
}
}
# aws_security_group.terraform_sg will be created
+ resource "aws_security_group" "terraform_sg" {
+ name = "terraform-project-2-sg"
...
}
Plan: 3 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ instance_id = (known after apply)
+ instance_public_ip = (known after apply)
+ ssh_command = (known after apply)
+ web_url = (known after apply)
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
The note about -out is worth understanding since it's a genuinely useful practice, not just a leftover message.
For a learning project run interactively in one sitting, this isn't critical. This is shown in PART 5 below.
$ terraform applyTerraform will show the plan again and ask for confirmation.
Type yes and press Enter.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
You will see something like this:
aws_key_pair.terraform_key: Creating...
aws_security_group.terraform_sg: Creating...
aws_key_pair.terraform_key: Creation complete after 1s [id=terraform-project-2-key]
aws_security_group.terraform_sg: Creation complete after 4s [id=sg-062b8c0ec3566eea0]
aws_instance.web: Creating...
Wait ~30 seconds. When it finishes, you'll see:
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
instance_id = "i-0601039e51bf10ed4"
instance_public_ip = "98.88.26.133"
ssh_command = "ssh -i ~/.ssh/id_rsa ubuntu@98.88.26.133"
web_url = "http://98.88.26.133"
Alternate way to run apply. No re-evaluation, no chance that something in AWS changed between your plan and apply commands and silently altered what gets created.
It becomes important in CI/CD pipelines, where plan and apply might run as separate automated steps minutes or hours apart, and you want a guarantee that what gets approved in plan is exactly what gets applied
$ terraform plan -out=tfplan
$ terraform apply tfplanThis saves the exact plan to a file, and apply then executes precisely that saved plan
Copy the ssh_command from the output and run it:
$ ssh -i ~/.ssh/id_rsa ubuntu@54.123.45.67You should land inside your AWS server:
Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-1016-aws x86_64)
ubuntu@ip-172-31-23-172:~$
You're in. Type exit to leave.
If you need the IP or SSH command later without running apply again:
$ terraform output$ terraform applyRun apply a second time. Notice:
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
0 changed — Terraform checked the real state of AWS, compared it to your .tf files,
and confirmed everything already matches. Nothing to do.
This is called idempotency — the same concept from Ansible's changed=0.
Your infrastructure is always in the exact state your code describes.
When you're done, delete everything with one command:
$ terraform destroyType yes when prompted.
Destroy complete! Resources: 3 destroyed.
The EC2 instance, security group, and key pair are all gone. No clicking in the AWS Console required.
Always destroy when done. Even though t3.micro is free tier, it's good practice. A stopped instance can still incur EBS storage costs.
Terraform is an Infrastructure as Code tool. Instead of clicking through a web console to create servers, you write code that describes what you want, and Terraform creates it. This makes infrastructure:
- Repeatable — run it 10 times, get the same result
- Version controlled — your infrastructure lives in Git like any other code
- Destroyable —
terraform destroyremoves everything cleanly
| Terraform | Ansible | |
|---|---|---|
| What it does | Creates infrastructure (servers, networks, storage) | Configures existing servers (installs software, sets up users) |
| Mental model | "Make this server exist" | "Make this server look like this" |
| Language | HCL (HashiCorp Configuration Language) | YAML |
| Runs against | Cloud APIs (AWS, GCP, Azure) | SSH into servers |
| Project A used | ❌ | ✅ |
| Project B uses | ✅ | ❌ |
| Project C will use | ✅ | ✅ |
When you run terraform apply, Terraform saves a record of what it created in a file called terraform.tfstate. This is how Terraform knows what already exists on your next run.
Never delete terraform.tfstate — Terraform needs it to track what's in AWS.
Never commit terraform.tfstate to GitHub — it's in .gitignore for this reason.
| Command | What it does |
|---|---|
terraform init |
Downloads providers. Run once per project. |
terraform plan |
Dry run — shows what would change. Always run before apply. |
terraform apply |
Creates/updates infrastructure to match your .tf files. |
terraform destroy |
Deletes everything Terraform created. |
"Error: No valid credential sources found"
Your AWS credentials aren't configured. Run aws configure and enter your access key and secret.
"Error: InvalidAMIID.NotFound"
The AMI ID in variables.tf is region-specific. If you changed aws_region, update ami_id to match the Ubuntu 24.04 AMI in your chosen region.
SSH "Permission denied (publickey)" Make sure your private key has correct permissions:
chmod 400 ~/.ssh/id_rsaSSH "Connection timed out"
Wait 60–90 seconds after terraform apply — EC2 instances take a moment to fully boot.
$ git init
$ git add .
$ git commit -m "Project 2: Terraform EC2 instance with security group and SSH key pair"
$ git remote add origin https://github.com/bysaania/terraform-project-2.git
$ git push -u origin mainThis project uses the classic security group style — ingress and egress rules nested directly inside the aws_security_group resource block. It's the clearest way to learn what a security group actually is.
There's also a modern style that HashiCorp now recommends, where each rule is its own separate resource (aws_vpc_security_group_ingress_rule, aws_vpc_security_group_egress_rule) linked back to the security group by ID. This pattern is more common in production codebases because each rule can be added, removed, or changed independently without touching the whole security group.
A fully commented reference version is included at:
alternate/main-modern-sg-style.tf.example
This file is intentionally not loaded by Terraform. The .tf.example extension means terraform init/apply will never read it, so it can't conflict with the real main.tf — even though both files define resources with the same names. If you want to actually run the modern version, copy it into its own separate project folder (with its own variables.tf and outputs.tf), rename it to main.tf, and run Terraform from there.
Classic (used in main.tf) |
Modern (reference only) | |
|---|---|---|
| Rules location | Nested inside the security group | Separate resources |
| Good for | Learning, small projects | Production, many rules |
| Resource count | 1 | 1 + 1 per rule |
| File | main.tf |
alternate/main-modern-sg-style.tf.example |
- Project C: Use Terraform to create the EC2 instance, then Ansible to configure it — the full Infrastructure as Code story
Part of my cloud/DevOps learning journey. Built as a beginner — intentionally simple. linkedin.com/in/saania-khanna