Today I deployed a fully functional web server on AWS. Not by clicking through the console, not by following a point-and-click wizard, but by writing code. Terraform read that code, figured out what needed to exist, and built it. That shift in thinking from doing things manually to describing what you want, is what Infrastructure as Code is all about.
Here is exactly how I did it, step by step.
What I Built:
- An EC2 instance (Amazon Linux 2, t2.micro — Free Tier eligible) running Apache HTTP Server
- A Security Group allowing HTTP traffic on port 80
- A custom HTML page served directly from the instance, injected at launch via a user data script
- Output blocks that printed the public IP once the deployment finished
Understanding the Two Core Building Blocks
Before diving into the code, it helps to understand the two blocks you will use in almost every Terraform configuration.
1. The Provider Block
The provider block tells Terraform where to deploy. It specifies which cloud platform you are targeting and how to authenticate with it. Think of it as Terraform’s starting point before it can create anything, it needs to know which API to talk to.
provider "aws" {
region = "us-east-1"
}
This single block tells Terraform: use AWS, deploy everything in the us-east-1 region (N. Virginia) - use your default region, and pick up authentication credentials from the AWS CLI configuration.
2. The Resource Block
The resource block tells Terraform what to deploy. Every piece of infrastructure; an EC2 instance, a security group, an S3 bucket, a VPC is declared as a resource block.
resource "aws_instance" "web_server" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
}
Ensure to use the AMI ID available in your region
The Full Terraform Code
Here is the complete main.tf I used for deployment.
# Configure the AWS provider
provider "aws" {
region = "eu-north-1"
}
resource "aws_security_group" "web_sg" {
name = "web-sg"
description = "Allow HTTP traffic"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "web_server" {
ami = "ami-0028809abe98af413"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web_sg.id]
associate_public_ip_address = true
user_data = <<-EOF
#!/bin/bash
apt update -y
apt install nginx -y
systemctl start nginx
systemctl enable nginx
echo "Hello, World! This is my first server deployed with Terraform." > /var/www/html/index.html
EOF
#so that when you change the user_data parameter and run apply,Terraform will terminate the original instance and launch a totally new one.
user_data_replace_on_change = true
tags = {
Name = "first-iac-Server"
}
}
output "pubic_ip" {
value = aws_instance.web_server.public_ip
description = "web_server's public ip"
}
The security group controls what traffic can reach the instance. Port 80 is open so anyone can view the web page in a browser. The egress block allows all outbound traffic, the instance needs to reach the internet to download packages during setup.
The user_data field is a shell script that runs automatically the first time the instance boots. It updates the system, installs nginx, starts the web server, and writes the HTML page to the default nginx directory. This is how you bootstrap infrastructure at launch without SSH-ing into it manually.
Notice vpc_security_group_ids = [aws_security_group.web_sg.id]: this is Terraform resolving a resource dependency also know as implicit dependency. Instead of hardcoding the security group ID, I reference it by name. Terraform know it must create the security group first, before the EC2 instance.
Output blocks expose values after deployment. Once terraform apply finishes, these print directly in the terminal, no need to log into the AWS console to find the IP.
The Terraform Workflow
terraform init
This is always the first command. It reads your configuration, identifies which providers you are using (AWS in this case), and downloads the necessary provider plugins into a .terraform directory. Nothing is created yet, this is purely setup.
terraform fmt & terraform validate
terraform fmt automatically formats your code to the standard Terraform style, consistent indentation, spacing, alignment.
terraform validate checks the configuration for syntax errors and catches mistakes before you spend time waiting for a plan.
terraform plan
This is Terraform’s dry run. It reads your configuration, compares it against what currently exists in AWS (nothing, in this case), and produces a detailed execution plan showing exactly what it will create, change, or destroy. No resources are touched at this stage. It is your safety net, always read the plan before applying.
terraform apply
This is where Terraform actually builds the infrastructure. It presents the plan one more time and asks for confirmation. Type yes and it provisions everything: security group first, then the EC2 instance, then prints the outputs.
Access http://public_ip in the browser to load the custom page.
terraform destroy
Run this to destroy resources previously created and clean up your environment.
Top comments (0)