π‘
This blog will guide you through the steps to set up an Nginx web server on AWS using Terraform and GitHub. It's a good starting point if you're learning DevOps or just getting started with infrastructure-as-code (IaC)
Prerequisites
- AWS Account (with credentials set up)
- Terraform is installed on your local machine
- GitHub Account
Letβs get started
First, letβs ensure our local workspace is set up properly to get started. Check if the AWS CLI is installed and if your credentials are set up. Use the commands below to verify
PS C:\> aws --version
aws-cli/2.28.16 Python/3.13.7 Windows/11 exe/AMD64
There will be a credentials file as follows. The file is located under aws folder in your home or user profile folder, based on Linux or Windows, respectively.
aws_access_key_id=************
aws_secret_access_key=************
Next, confirm if Terraform is installed
PS C:\> terraform version
Terraform v1.13.2
on windows_amd64
Letβs move on to GitHub repository creation and coding, as our local environment is ready.
Create a GitHub repo and clone it to your local IDE workspace (in this case, letβs work with VS Code)
Hereβs how weβll organize our files:
sws-terraform/
βββ provider.tf # Define Cloud provider
βββ main.tf # All AWS resources
βββ data.tf # Retrieve info on AWS resources
βββ variables.tf # Input variables
βββ outputs.tf # Output values
βββ scripts # EC2 startup script
|ββ userdata.sh # EC2 startup script
Note: A .tf file is a Terraform configuration file written in HashiCorp Configuration Language (HCL). It defines the infrastructure resource you want to create, manage, or destroy using Terraform.
More information and resources to learn about Terraform can be found at this link
Code Walkthrough
Provider.tf
This file is used to configure the cloud provider. Here in this was its AWS. This tells Terraform which cloud platform to use, which region to deploy the resources (in our case, itβs a web server), and optionally which version of the provider plugin to use.
# Setting AWS as provider
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.40.0"
}
}
}
#Set up the default region where the web server will be launched
provider "aws" {
region = "us-east-1"
}
Variables.tf
This file allows us to define input parameters that make infrastructure code flexible, reusable, and easier to manage.
variable "ec2instancetype" {
default = "t2.micro"
}
variable "http_port" {
default = "80"
}
variable "tcp_protocol" {
default = "tcp"
}
variable "allow_all" {
default = "0.0.0.0/0"
}
data.tf
This file in Terraform is important because it defines data sources, which allow us to fetch and reference existing infrastructure and prevent us from wasting effort on duplication of resources
# Retrieve AWS Region details
data "aws_region" "current" {}
#Retrieve VPC details
data "aws_vpc" "main" {
default = true
}
# Retrieve subnet information for a specific availability zone & VPC
data "aws_subnet" "subnet_id" {
filter {
name = "availability-zone"
values = ["${data.aws_region.current.name}a"]
}
filter {
name = "vpc-id"
values = [data.aws_vpc.main.id]
}
}
# Retrieve AMI details for Ubuntu
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
owners = ["099720109477"] # Canonical
}
main.tf
This is the file where we create the resources required and serves as the central configuration file. This is the heart of our Terraform project.
#Security group for webserver
resource "aws_security_group" "mysg" {
vpc_id = data.aws_vpc.main.id
name = "sws-sg"
description = "security group for webserver public access"
# Rule to allow access to webserve through browser
ingress {
from_port = var.http_port
to_port = var.http_port
protocol = var.tcp_protocol
cidr_blocks = ["${var.allow_all}"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["${var.allow_all}"]
}
}
resource "aws_instance" "sws" {
ami = data.aws_ami.ubuntu.id
instance_type = var.ec2instancetype
user_data = file("./scripts/userdata.sh")
subnet_id = data.aws_subnet.subnet_id.id
vpc_security_group_ids = [aws_security_group.mysg.id]
associate_public_ip_address = true
tags = {
Name = "sws"
Environment = "test"
}
}
output.tf
This file is used to get the information on resources we just created in our central configuration file.
output "sws_url" {
value = aws_instance.sws.public_dns
}
userdata.sh
This file is used as a startup script when EC2 launches and installs Nginx web server.
#!/bin/bash
# Simple NGINX install and start
sudo apt-get update -y
sudo apt-get install nginx -y
sudo systemctl enable nginx
sudo systemctl start nginx
# Optional: Add a welcome page
echo "<h1>Welcome from Terraform NGINX Server</h1>" | sudo tee /var/www/html/index.html
Note: You can place all your Terraform code in a single file like main.tf, or split it into multiple files (e.g., variables.tf, outputs.tf, etc.). Both approaches work the same way in Terraform, but breaking the code into separate files improves readability, organization, and makes your configuration more modular and maintainable.
Once we complete the coding part, letβs publish the code to GitHub and move on to initiate and execute the Terraform configuration file.
Open the command prompt or shell, based on which operating system (Windows or Linux), then run the following commands.
terraform init
To initialize the working directory and set up everything Terraform needs to manage the infrastructure.
PS C:\Apps\Infra\sws-terraform> terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "5.40.0"...
- Installing hashicorp/aws v5.40.0...
- Installed hashicorp/aws v5.40.0 (signed by HashiCorp)
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!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
PS C:\Apps\Infra\sws-terraform>
terraform validate
This is used to check the syntax and internal consistency of Terraform configuration files without accessing any remote services like AWS.
PS C:\Apps\Infra\sws-terraform> terraform validate
Success! The configuration is valid.
PS C:\Apps\Infra\\sws-terraform>
terraform plan
This command is used to preview the changes Terraform will make to our infrastructure before actually applying them.
PS C:\Apps\Infra\sws-terraform> terraform plan
data.aws_region.current: Reading...
data.aws_vpc.main: Reading...
data.aws_ami.ubuntu: Reading...
data.aws_region.current: Read complete after 0s [id=us-east-1]
data.aws_ami.ubuntu: Read complete after 2s [id=ami-090c309e8ced8ecc2]
data.aws_vpc.main: Read complete after 5s [id=vpc-12345]
data.aws_subnet.subnet_id: Reading...
data.aws_subnet.subnet_id: Read complete after 0s [id=subnet-12345]
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.sws will be created
+ resource "aws_instance" "sws" {
+ ami = "ami-090c309e8ced8ecc2"
+ arn = (known after apply)
+ associate_public_ip_address = true
+ availability_zone = (known after apply)
+ cpu_core_count = (known after apply)
+ cpu_threads_per_core = (known after apply)
+ disable_api_stop = (known after apply)
+ disable_api_termination = (known after apply)
+ ebs_optimized = (known after apply)
+ get_password_data = false
+ host_id = (known after apply)
+ host_resource_group_arn = (known after apply)
+ iam_instance_profile = (known after apply)
+ id = (known after apply)
+ instance_initiated_shutdown_behavior = (known after apply)
+ instance_lifecycle = (known after apply)
+ instance_state = (known after apply)
+ instance_type = "t2.micro"
+ ipv6_address_count = (known after apply)
+ ipv6_addresses = (known after apply)
+ key_name = (known after apply)
+ monitoring = (known after apply)
+ outpost_arn = (known after apply)
+ password_data = (known after apply)
+ placement_group = (known after apply)
+ placement_partition_number = (known after apply)
+ primary_network_interface_id = (known after apply)
+ private_dns = (known after apply)
+ private_ip = (known after apply)
+ public_dns = (known after apply)
+ public_ip = (known after apply)
+ secondary_private_ips = (known after apply)
+ security_groups = (known after apply)
+ source_dest_check = true
+ spot_instance_request_id = (known after apply)
+ subnet_id = "subnet-12345"
+ tags = {
+ "Environment" = "test"
+ "Name" = "sws"
}
+ tags_all = {
+ "Environment" = "test"
+ "Name" = "sws"
}
+ tenancy = (known after apply)
+ user_data = "0a4c91c3ccc289366e3fd4458849e719065eb095"
+ user_data_base64 = (known after apply)
+ user_data_replace_on_change = false
+ vpc_security_group_ids = (known after apply)
+ capacity_reservation_specification (known after apply)
+ cpu_options (known after apply)
+ ebs_block_device (known after apply)
+ enclave_options (known after apply)
+ ephemeral_block_device (known after apply)
+ instance_market_options (known after apply)
+ maintenance_options (known after apply)
+ metadata_options (known after apply)
+ network_interface (known after apply)
+ private_dns_name_options (known after apply)
+ root_block_device (known after apply)
}
# aws_security_group.mysg will be created
+ resource "aws_security_group" "mysg" {
+ arn = (known after apply)
+ description = "security group for webserver public access"
+ egress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 0
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "-1"
+ security_groups = []
+ self = false
+ to_port = 0
# (1 unchanged attribute hidden)
},
]
+ id = (known after apply)
+ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 80
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 80
# (1 unchanged attribute hidden)
},
]
+ name = "sws-sg"
+ name_prefix = (known after apply)
+ owner_id = (known after apply)
+ revoke_rules_on_delete = false
+ tags_all = (known after apply)
+ vpc_id = "vpc-12345"
}
Plan: 2 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ sws_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.
PS C:\Apps\Infra\sws-terraform>
terraform apply
This is the final step that actually provisions the required infrastructure from our code. It also asks for our confirmation before proceeding to create the resources. We have to enter βyesβ to proceed or approve the changes; otherwise, the process will terminate. Also, another important feature is that it works with remote backends and state files to track the infrastructure. Here, as we are working locally, it creates a local state file to manage it.
PS C:\Apps\Infra\sws-terraform> terraform apply
data.aws_vpc.main: Reading...
data.aws_region.current: Reading...
data.aws_ami.ubuntu: Reading...
data.aws_region.current: Read complete after 0s [id=us-east-1]
data.aws_ami.ubuntu: Read complete after 3s [id=ami-090c309e8ced8ecc2]
data.aws_vpc.main: Read complete after 5s [id=vpc-12345]
data.aws_subnet.subnet_id: Reading...
data.aws_subnet.subnet_id: Read complete after 1s [id=subnet-12345]
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.sws will be created
+ resource "aws_instance" "sws" {
+ ami = "ami-090c309e8ced8ecc2"
+ arn = (known after apply)
+ associate_public_ip_address = true
+ availability_zone = (known after apply)
+ cpu_core_count = (known after apply)
+ cpu_threads_per_core = (known after apply)
+ disable_api_stop = (known after apply)
+ disable_api_termination = (known after apply)
+ ebs_optimized = (known after apply)
+ get_password_data = false
+ host_id = (known after apply)
+ host_resource_group_arn = (known after apply)
+ iam_instance_profile = (known after apply)
+ id = (known after apply)
+ instance_initiated_shutdown_behavior = (known after apply)
+ instance_lifecycle = (known after apply)
+ instance_state = (known after apply)
+ instance_type = "t2.micro"
+ ipv6_address_count = (known after apply)
+ ipv6_addresses = (known after apply)
+ key_name = (known after apply)
+ monitoring = (known after apply)
+ outpost_arn = (known after apply)
+ password_data = (known after apply)
+ placement_group = (known after apply)
+ placement_partition_number = (known after apply)
+ primary_network_interface_id = (known after apply)
+ private_dns = (known after apply)
+ private_ip = (known after apply)
+ public_dns = (known after apply)
+ public_ip = (known after apply)
+ secondary_private_ips = (known after apply)
+ security_groups = (known after apply)
+ source_dest_check = true
+ spot_instance_request_id = (known after apply)
+ subnet_id = "subnet-12345"
+ tags = {
+ "Environment" = "test"
+ "Name" = "sws"
}
+ tags_all = {
+ "Environment" = "test"
+ "Name" = "sws"
}
+ tenancy = (known after apply)
+ user_data = "0a4c91c3ccc289366e3fd4458849e719065eb095"
+ user_data_base64 = (known after apply)
+ user_data_replace_on_change = false
+ vpc_security_group_ids = (known after apply)
+ capacity_reservation_specification (known after apply)
+ cpu_options (known after apply)
+ ebs_block_device (known after apply)
+ enclave_options (known after apply)
+ ephemeral_block_device (known after apply)
+ instance_market_options (known after apply)
+ maintenance_options (known after apply)
+ metadata_options (known after apply)
+ network_interface (known after apply)
+ private_dns_name_options (known after apply)
+ root_block_device (known after apply)
}
# aws_security_group.mysg will be created
+ resource "aws_security_group" "mysg" {
+ arn = (known after apply)
+ description = "security group for webserver public access"
+ egress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 0
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "-1"
+ security_groups = []
+ self = false
+ to_port = 0
# (1 unchanged attribute hidden)
},
]
+ id = (known after apply)
+ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 80
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 80
# (1 unchanged attribute hidden)
},
]
+ name = "sws-sg"
+ name_prefix = (known after apply)
+ owner_id = (known after apply)
+ revoke_rules_on_delete = false
+ tags_all = (known after apply)
+ vpc_id = "vpc-12345"
}
Plan: 2 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ sws_url = (known after apply)
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
aws_security_group.mysg: Creating...
aws_security_group.mysg: Creation complete after 9s [id=sg-12345]
aws_instance.sws: Creating...
aws_instance.sws: Still creating... [00m10s elapsed]
aws_instance.sws: Still creating... [00m20s elapsed]
aws_instance.sws: Creation complete after 29s [id=i-0f61356c5325cf87f]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
sws_url = "ec2-54-204-96-178.compute-1.amazonaws.com"
PS C:\Apps\Infra\sws-terraform>
Finally, we have created our web server successfully, and we have received the public DNS URL as the output. Letβs try to access it from the browser
As shown in the image above, weβve successfully accessed our web server and received the Nginx default home page β all in under 30 minutes. π
The best part? We now have reusable infrastructure code that can spin up and tear down a web server whenever we need it.
Now, letβs move on to the final step: destroying the resources, which is just as simple.
terraform destroy
This is the command used to delete all the infrastructure that Terraform has created and is currently managing (through the local state file). Similar to the terraform apply command, we need to provide our approval before proceeding to destroy the resources.
PS C:\Apps\Infra\sws-terraform> terraform destroy
data.aws_ami.ubuntu: Reading...
data.aws_vpc.main: Reading...
data.aws_region.current: Reading...
data.aws_region.current: Read complete after 0s [id=us-east-1]
data.aws_ami.ubuntu: Read complete after 3s [id=ami-090c309e8ced8ecc2]
data.aws_vpc.main: Read complete after 5s [id=vpc-12345]
data.aws_subnet.subnet_id: Reading...
aws_security_group.mysg: Refreshing state... [id=sg-0145aa368f15142c6]
data.aws_subnet.subnet_id: Read complete after 1s [id=subnet-12345]
aws_instance.sws: Refreshing state... [id=i-0f61356c5325cf87f]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# aws_instance.sws will be destroyed
- resource "aws_instance" "sws" {
- ami = "ami-090c309e8ced8ecc2" -> null
- arn = "arn:aws:ec2:us-east-1:12345:instance/i-0f61356c5325cf87f" -> null
- associate_public_ip_address = true -> null
- availability_zone = "us-east-1a" -> null
- cpu_core_count = 1 -> null
- cpu_threads_per_core = 1 -> null
- disable_api_stop = false -> null
- disable_api_termination = false -> null
- ebs_optimized = false -> null
- get_password_data = false -> null
- hibernation = false -> null
- id = "i-0f61356c5325cf87f" -> null
- instance_initiated_shutdown_behavior = "stop" -> null
- instance_state = "running" -> null
- instance_type = "t2.micro" -> null
- ipv6_address_count = 0 -> null
- ipv6_addresses = [] -> null
- monitoring = false -> null
- placement_partition_number = 0 -> null
- primary_network_interface_id = "eni-037d2cbb828ac6e82" -> null
- private_dns = "ip-172-31-45-168.ec2.internal" -> null
- private_ip = "172.31.45.168" -> null
- public_dns = "ec2-54-204-96-178.compute-1.amazonaws.com" -> null
- public_ip = "54.204.96.178" -> null
- secondary_private_ips = [] -> null
- security_groups = [
- "sws-sg",
] -> null
- source_dest_check = true -> null
- subnet_id = "subnet-12345" -> null
- tags = {
- "Environment" = "test"
- "Name" = "sws"
} -> null
- tags_all = {
- "Environment" = "test"
- "Name" = "sws"
} -> null
- tenancy = "default" -> null
- user_data = "0a4c91c3ccc289366e3fd4458849e719065eb095" -> null
- user_data_replace_on_change = false -> null
- vpc_security_group_ids = [
- "sg-12345",
] -> null
# (8 unchanged attributes hidden)
- capacity_reservation_specification {
- capacity_reservation_preference = "open" -> null
}
- cpu_options {
- core_count = 1 -> null
- threads_per_core = 1 -> null
# (1 unchanged attribute hidden)
}
- credit_specification {
- cpu_credits = "standard" -> null
}
- enclave_options {
- enabled = false -> null
}
- maintenance_options {
- auto_recovery = "default" -> null
}
- metadata_options {
- http_endpoint = "enabled" -> null
- http_protocol_ipv6 = "disabled" -> null
- http_put_response_hop_limit = 1 -> null
- http_tokens = "optional" -> null
- instance_metadata_tags = "disabled" -> null
}
- private_dns_name_options {
- enable_resource_name_dns_a_record = false -> null
- enable_resource_name_dns_aaaa_record = false -> null
- hostname_type = "ip-name" -> null
}
- root_block_device {
- delete_on_termination = true -> null
- device_name = "/dev/sda1" -> null
- encrypted = false -> null
- iops = 100 -> null
- tags = {} -> null
- tags_all = {} -> null
- throughput = 0 -> null
- volume_id = "vol-02896cd14baeffc82" -> null
- volume_size = 8 -> null
- volume_type = "gp2" -> null
# (1 unchanged attribute hidden)
}
}
# aws_security_group.mysg will be destroyed
- resource "aws_security_group" "mysg" {
- arn = "arn:aws:ec2:us-east-1:12345:security-group/sg-12345" -> null
- description = "security group for webserver public access" -> null
- egress = [
- {
- cidr_blocks = [
- "0.0.0.0/0",
]
- from_port = 0
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "-1"
- security_groups = []
- self = false
- to_port = 0
# (1 unchanged attribute hidden)
},
] -> null
- id = "sg-12345" -> null
- ingress = [
- {
- cidr_blocks = [
- "0.0.0.0/0",
]
- from_port = 80
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "tcp"
- security_groups = []
- self = false
- to_port = 80
# (1 unchanged attribute hidden)
},
] -> null
- name = "sws-sg" -> null
- owner_id = "12345" -> null
- revoke_rules_on_delete = false -> null
- tags = {} -> null
- tags_all = {} -> null
- vpc_id = "vpc-12345" -> null
# (1 unchanged attribute hidden)
}
Plan: 0 to add, 0 to change, 2 to destroy.
Changes to Outputs:
- sws_url = "ec2-54-204-96-178.compute-1.amazonaws.com" -> null
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
aws_instance.sws: Destroying... [id=i-0f61356c5325cf87f]
aws_instance.sws: Still destroying... [id=i-0f61356c5325cf87f, 00m10s elapsed]
aws_instance.sws: Still destroying... [id=i-0f61356c5325cf87f, 00m20s elapsed]
aws_instance.sws: Still destroying... [id=i-0f61356c5325cf87f, 00m30s elapsed]
aws_instance.sws: Destruction complete after 35s
aws_security_group.mysg: Destroying... [id=sg-12345]
aws_security_group.mysg: Destruction complete after 2s
Destroy complete! Resources: 2 destroyed.
PS C:\Apps\Infra\sws-terraform>
π§Ή With everything deployed and destroyed smoothly, we have now taken our first step into Infrastructure as Code using Terraform. Stay tuned for more hands-on DevOps projects β and keep learning, sharing, and evolving. π
Originally published on my blog (at this link)
Top comments (0)