π‘
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)