As I continue my Terraform learning journey, one of the most important lessons I have learned is that writing infrastructure that merely works is not enough. Real-world infrastructure needs to be reusable, scalable, and resilient. On this project, I moved beyond deploying a single EC2 instance and built a more flexible and highly available web application architecture on AWS using Terraform.
This project helped me understand two important ideas in Infrastructure as Code. First, I learned how to apply the DRY principle by replacing hardcoded values with input variables. Second, I learned how to improve reliability by deploying multiple web servers behind an Application Load Balancer with an Auto Scaling Group. These changes transformed a basic setup into something much closer to production-style infrastructure.
Why I Refactored the Initial Deployment
My starting point was a simple Terraform deployment of a single web server. While that setup was useful for learning the basics, it had several limitations. The configuration contained hardcoded values such as the AWS region, EC2 instance type, and application port. This made the code less reusable and harder to maintain. If I wanted to make a small change, I had to edit the main configuration directly.
Another major limitation was availability. A single EC2 instance creates a single point of failure. If that instance becomes unhealthy or stops running, the application becomes unavailable. That is not ideal for any system that users depend on.
To address these issues, I refactored the infrastructure in two stages. I first made the single-server deployment configurable using variables. After that, I extended the design into a clustered deployment using AWS load balancing and auto scaling features.
Applying the DRY Principle with Terraform Variables
One of the key goals of this project was to make the configuration cleaner and more reusable. Instead of repeating values or hardcoding them into resource definitions, I moved them into input variables. This made the Terraform code easier to understand and update.
For example, values such as the server port, instance type, environment, project name, and allowed CIDR blocks were all defined as variables. This means the same Terraform configuration can be reused across different environments with minimal changes. A developer can deploy the same infrastructure in development, testing, or production simply by supplying different values.
Using variables also improved readability. The infrastructure logic stayed in the main resource definitions, while configuration details were separated into dedicated variable declarations. This separation made the code more organized and aligned with good Infrastructure as Code practices.
Using Data Sources to Avoid Hardcoding AWS Details
Another important improvement was the use of Terraform data sources. Instead of hardcoding AWS-specific values such as availability zones or machine images, I used Terraform data blocks to query AWS dynamically.
For example, I used the aws_availability_zones data source to retrieve the available availability zones in the selected region. This made the deployment more portable because it can adapt to whichever region is being used. I also used a data source to fetch the most recent Amazon Linux AMI instead of manually entering an AMI ID.
This approach makes the configuration more reliable and easier to maintain. Hardcoded infrastructure values can become outdated quickly, especially AMI IDs. Data sources help Terraform discover the current environment automatically, which reduces manual effort and lowers the risk of configuration errors.
Building the Configurable Web Server
The first stage of the refactor was creating a configurable web server deployment. This version still used a single EC2 instance, but it was much more flexible than the original setup.
The deployment included a provider configuration, a default VPC, a default subnet, a security group, and an EC2 instance. The EC2 instance used a user data script to install Python, generate a simple HTML page, and run a lightweight HTTP server. The web page displayed a custom message along with deployment details such as the environment and port number.
The security group allowed inbound HTTP traffic on the application port and SSH access on port 22. Because the port was defined as a variable, the application could easily be reconfigured to run on a different port without changing the structure of the resource blocks.
Although this stage still relied on a single instance, it was a significant improvement over the original design because it introduced modularity and reuse.
Moving to a Highly Available Clustered Architecture
The second stage of the project was the most exciting because it introduced high availability. Instead of depending on one EC2 instance, I deployed multiple instances using an Auto Scaling Group and distributed traffic through an Application Load Balancer.
In this design, the Application Load Balancer receives incoming traffic from users on port 80 and forwards requests to a target group. The target group routes traffic to EC2 instances created by the Auto Scaling Group. These instances are launched from a launch template, which defines the AMI, instance type, security group, and startup script.
The Auto Scaling Group ensures that the desired number of instances is always running. If one instance fails, AWS can replace it automatically. This removes the single point of failure that existed in the earlier deployment. It also lays the foundation for scaling, since the group can be configured to increase or decrease capacity as needed.
By spreading the deployment across multiple availability zones, the application becomes even more resilient. If one availability zone has an issue, traffic can still be served from instances in another zone.
How the Main Components Work Together
This project helped me clearly understand how several AWS services connect in a real deployment.
The launch template acts as the blueprint for the EC2 instances. It defines what each server should look like when it is created.
The Auto Scaling Group uses that blueprint to launch and manage multiple instances. It ensures that the defined number of servers is available at all times.
The Application Load Balancer acts as the public entry point for the application. Instead of users connecting directly to a specific EC2 instance, they connect to the load balancer. The load balancer then forwards traffic to healthy instances.
The target group serves as the connection point between the load balancer and the EC2 instances. It also performs health checks to verify that the instances are responding correctly.
Finally, the security groups control traffic flow. The load balancer security group allows public HTTP access, while the instance security group only allows application traffic from the load balancer and optional SSH access from approved IP ranges.
Seeing these pieces work together helped me understand why modern cloud infrastructure is designed as a system of cooperating resources instead of isolated servers.
Challenges I Encountered
This project was very educational, but it also came with several challenges. One of the biggest challenges was making sure that the ports matched everywhere. The application had to listen on the same port that the target group expected, and the security groups had to allow that same traffic. A mismatch in any one of these places could cause health checks to fail and prevent the application from working.
Another challenge was understanding the relationship between the Application Load Balancer, target group, listener, and Auto Scaling Group. At first, these resources seemed like separate pieces, but I gradually learned that they form a chain. The listener accepts traffic, the target group defines where the traffic goes, and the Auto Scaling Group supplies the EC2 instances that receive the requests.
Health checks were also an important learning point. If the web application was not serving content correctly at the expected path and port, the instances were marked unhealthy. This showed me how AWS validates service availability before routing user traffic.
What I Learned
This project taught me that Terraform is much more powerful when infrastructure is written with flexibility and resilience in mind. Variables made the code cleaner and easier to reuse. Data sources made it more adaptive to the AWS environment. The Auto Scaling Group and Application Load Balancer introduced fault tolerance and high availability.
More importantly, I learned the difference between a simple lab deployment and a more realistic architecture. A single EC2 instance is useful for getting started, but a load-balanced multi-instance setup reflects how real systems are designed for reliability.
I also gained more confidence in reading Terraform resource relationships. Instead of seeing each block as isolated, I began to understand how they connect to form a complete infrastructure solution.
Final Thoughts
This was one of the most valuable Terraform projects I have completed so far because it showed me how to move from a hardcoded, single-server setup to a reusable and highly available deployment on AWS.
By applying the DRY principle, I made the infrastructure easier to maintain and customize. By introducing an Application Load Balancer and Auto Scaling Group, I made the application more resilient and better suited for real-world use.
Projects like this remind me that Infrastructure as Code is not just about automation. It is also about writing infrastructure that is clean, scalable, and dependable. That is what makes Terraform such an important tool in modern cloud engineering.
Top comments (0)