Day #47: [DAY-22] Two Tier Architecture Setup on AWS Using Terraform
Let’s learn how to build a secure two-tier architecture on AWS, while keeping secrets safe and infrastructure organized.
![Day #47: [DAY-22] Two Tier Architecture Setup on AWS Using Terraform](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1766423595267%2F6582f7e7-0485-445b-9117-36f5abbe5d44.png&w=3840&q=75)
Introduction
Hello everyone, and welcome back to Day 22.
If you’ve been following along on this learning journey with us, thank you for being here again. And if you’re just joining in, you’re very welcome too. This series has always been about learning together: without rushing through the fundamentals.
In the previous blog, we spent our time understanding AWS Policy and Governance using Terraform. We talked about how guardrails, permissions, and governance are not just “enterprise things,” but essential building blocks for any real system, no matter how small it starts. That foundation matters, because once we begin creating actual infrastructure, those decisions quietly shape everything that follows.
Today, we’re taking the next natural step.
Instead of talking only about policies and structure, we’re going to build something tangible i.e. a small but meaningful two-tier architecture on AWS using Terraform.

If a mini project carries this much complexity, just imagine what a real-world production setup looks like. This is also why challenges like our 30 days of learning exist, to expose us to these layers without overwhelming us all at once.
In this blog, we’ll walk through an architecture where:
A web application runs on an EC2 instance
A managed MySQL database lives securely in RDS
Networking, security groups, and access boundaries are clearly defined
Sensitive database credentials are handled safely, not hardcoded
We’ll move step by step, telling the story of why each piece exists, how it connects to the next, and how Terraform helps us keep everything organized through custom modules.
So take your time, read slowly, and don’t worry if some concepts feel new.
Before we dive in here’s a fact for today:
Fact #22: I really like having routines and a clear plan… but somehow, I rarely stick to them exactly as I imagine. Some days I’m on track, and other days I’m juggling a dozen things at once. That’s part of why I took up this #30daysofawsterraform challenge as it gave me a framework that I could follow along.
Understanding the Two-Tier Architecture We’re Building
Before we touch Terraform or look at any files, it’s important that we first picture the system in our minds. When things feel confusing later, and they will at some point, this mental picture is what helps everything fall back into place.
At a very high level, a two-tier architecture simply means we are separating responsibilities into two clear layers.
The first tier is what users interact with.
The second tier is where the data lives.
In our case, the first tier is a web server running on an EC2 instance, and the second tier is a MySQL database running on Amazon RDS. Both of these live inside AWS, but they live very different lives.
Let’s start from the user’s perspective.
A user sends an HTTP request from their browser. That request enters AWS through an Internet Gateway and reaches our web server, which is hosted on an EC2 instance inside a public subnet. This server runs a simple Flask application on Ubuntu, and it listens on port 80, just enough to serve requests and respond.
This web server is protected by a security group that allows:
HTTP traffic on port 80 from the internet
SSH access (something we should always restrict to our own IP)
This is the only part of our system that the outside world can see.
Now, here’s where the second tier comes in.
Behind the scenes, our Flask application needs a place to store data. For that, we use Amazon RDS running MySQL. This database does not sit in the public subnet. Instead, it lives inside a private subnet, completely shielded from direct internet access.
And this is a very intentional decision.
The database:
Has no public access
Accepts traffic only on port 3306
Allows connections only from the web tier’s security group
In other words, even if someone knows the database exists, they cannot talk to it directly. The only trusted path to the database is through our web server.
There’s one more important piece that connects these two tiers in a secure way: AWS Secrets Manager.
Instead of writing database usernames and passwords inside our application or worse, inside Terraform files, we let Secrets Manager handle that responsibility. It generates the database password for us, stores it securely, and makes it available to the application when needed.
This is how our web server learns how to talk to the database without ever hardcoding sensitive information.
This is the essence of the two-tier architecture we’re building.
In the next section, we’ll start looking at how Terraform helps us organize all of this using custom modules, and how the root module ties everything together without becoming messy.
How We Organize This Infrastructure Using Terraform Modules
As soon as infrastructure grows beyond a few resources, things can get overwhelming very quickly. This is exactly why, in this project, we’re not placing everything into one large Terraform file. Instead, we’re using custom Terraform modules to keep each responsibility clearly separated.
If custom modules are still new to you, that’s completely okay. We’ve already explored them in detail earlier in this journey, especially in the blog where we implemented a real-time project using modules. The idea here is the same, we’re simply applying that structure to a two-tier architecture.
At the center of everything, we have the root module. This is the starting point.
When we open the root module, the first thing we define is the Terraform setup itself.
We specify the Terraform version we expect and the AWS provider version we want to use. This helps ensure consistency, especially when others pull the project and run it on their machines. We also configure the AWS provider using a region that comes from variables, keeping the setup flexible and reusable.
Once that foundation is in place, we begin calling our custom modules: one by one.
First module: Secrets module
Before we create servers or databases, we take a small but thoughtful pause to answer an important question:
Where should our database credentials live?
In many early projects, it’s common to see usernames and passwords written directly into code or configuration files. It works, but it also creates risk. In this setup, we deliberately choose a safer path by letting AWS Secrets Manager handle those sensitive details for us.
In the root module, the Secrets module is called very early.

Notice what we don’t pass into it, we never pass a password. The only database-related value we provide is the database username, along with the project name and environment. Everything else is handled automatically.
Inside the Secrets module, the first thing we do is generate a random password.

Terraform’s random_password resource allows us to define simple rules, such as:
The password length
Whether special characters are included
Which special characters are allowed
This means the password is strong, unpredictable, and never manually created. Once generated, it exists only in Terraform’s state and in Secrets Manager, not in our code.
To avoid naming conflicts, we also generate a small random ID suffix.

This helps ensure that each secret has a unique name, even if we deploy the same project multiple times across environments.
With these pieces in place, we create an AWS Secrets Manager secret.

The name of the secret includes the project name, environment, and purpose, making it easy to identify later. We also add tags so the secret remains discoverable and organized inside AWS.
Next, we create a secret version, which is where the actual data lives.

Here, we store the credentials as a JSON structure. This JSON includes:
The database username passed from the root module
The randomly generated password
The database engine (MySQL)
A placeholder for the database host
Even though the database hasn’t been created yet, this is perfectly fine. Terraform allows us to define this structure now and connect the missing pieces later, once the database exists.
What’s important is the intent.
At no point do we hardcode secrets, and at no point does the application need to “know” where passwords come from. It simply consumes them securely when needed.
This approach gives us a few quiet but powerful benefits:
Credentials are centrally managed
Passwords can be rotated later without changing code
Sensitive values are never exposed in plain text files
With secrets handled safely, we can now move forward with confidence.
Second module: VPC module.
This module is responsible for laying down the networking foundation. It creates:
A VPC
A public subnet for the web server
Multiple private subnets for RDS, spread across availability zones
An internet gateway
Route tables and their associations
Because RDS is a managed service designed for high availability, it needs multiple private subnets.

That’s why we don’t just create one private subnet, we create more than one, ensuring resilience if an availability zone goes down.
It’s also important to remember that private subnets still need outbound access. This is where NAT gateways come into play.
They allow resources like RDS to communicate outward when required, without exposing them to inbound internet traffic.
Third module: Security groups
Here, responsibilities are clearly split:
- The web security group allows HTTP traffic on port 80 and SSH access (ideally restricted to our IP)

- The database security group allows MySQL traffic on port 3306, but only from the web security group

This setup ensures that the database trusts only the web tier and nothing else.
Fourth module: RDS module.
This module receives:
Private subnet IDs from the VPC module
The database security group ID
Database name and username from variables
The database password directly from the Secrets module

Notice how nothing is duplicated. Each module produces outputs, and the root module simply wires them together. This is how Terraform helps us build complex systems without losing clarity.
Final module: EC2 module
With the database securely running in the background, the final piece of our architecture is the web server. This is the part users interact with directly, and it’s created using the EC2 module.
When the EC2 module is called from the root module, it receives several important inputs from other parts of the system:
The public subnet ID from the VPC module, so the instance knows where to live
The web security group ID, so traffic rules are applied correctly
The database endpoint from the RDS module
The database credentials from the Secrets Manager module
Once again, nothing is duplicated. Each module provides exactly what it owns, and the EC2 module simply consumes what it needs.
Now, a natural question comes up here:
When does the application actually get deployed?
The answer is, at instance launch time.
Inside the EC2 module, we use a user data script. This script lives in a templates folder and is passed into the EC2 instance as part of its metadata. When the instance starts for the first time, this script runs automatically.
Within that user data script:
Required system packages are installed
Application dependencies are set up
A simple Flask application is created
Database connection details are injected into the application
This is how the application learns:
Which database to connect to
Which username and password to use
All of this happens without logging into the server manually.
The Flask application itself is intentionally simple. It has:
A home page
A health endpoint
A database info endpoint
Basic insert and read operations for messages
There are no delete or update operations here. The goal isn’t to build a full product, it’s to clearly demonstrate how a web tier and database tier communicate securely.
Once the EC2 instance is up and running, Terraform outputs the public DNS name of the server. This becomes the application URL that we can open in a browser.
At this stage, the entire flow is complete:
Users access the application via the public DNS
Requests hit the EC2 instance through the internet gateway
The Flask app talks to RDS using secure credentials
Data is stored safely in a private subnet
Everything we planned earlier is now working together.
Provisioning the Infrastructure and Testing the Application
At this point, all the pieces are defined. The modules are wired together, dependencies are clear, and Terraform understands the order in which everything needs to be created. Now comes the part where we simply let Terraform do its job.
From the project directory, we initialize Terraform. This step prepares the working directory, downloads the required provider, and gets everything ready for provisioning. Once that’s done, we run a Terraform plan.
The plan shows us exactly what Terraform is about to create. In our case, it lists all the resources i.e. VPC components, security groups, secrets, RDS, and the EC2 instance. Seeing this plan is reassuring. Nothing is hidden. Everything is explicit.
Once we’re comfortable with the plan, we apply it.
Terraform now starts creating resources in the correct sequence. You’ll notice that some resources come up quickly, while others, especially RDS, take a bit of time. This is expected. Managed database services don’t appear instantly, and Terraform patiently waits until they’re fully ready before moving on.
Eventually, the process completes, and all resources are provisioned.
Terraform then provides us with a few useful outputs:
The RDS endpoint
The public DNS name of the web server
The application URL
This is our moment of truth.
We take the public DNS name and open it in a browser. The page loads, and we see the application running. This confirms a few important things all at once:
The EC2 instance is reachable
The Flask application started successfully
Networking and security groups are working as expected
To test database connectivity, we try saving a message through the application. Once submitted, the message is stored in the MySQL database running in RDS.
And it works.
The message is saved, retrieved, and displayed, proving that:
The web server can talk to the database
Secrets were injected correctly
The private subnet and security group setup is doing its job
The application also exposes a few helpful endpoints. There’s a health endpoint that confirms database connectivity, and a database info endpoint that shows details like the database name, host, and MySQL version. These endpoints help us verify that everything behind the scenes is wired together correctly.
What’s important here isn’t the complexity of the application. It’s the clarity of the architecture. We now have a fully functioning two-tier system:
A public-facing web tier
A private, secure database tier
Clean separation of responsibilities
Everything managed through Terraform
Before we wrap up, there’s one very important reminder.
Resources like RDS cost money. Once you’re done exploring and testing, make sure to destroy the infrastructure. Terraform makes this easy, and it’s a habit worth building early.
Conclusion
And with that, we’ve completed Day 22 of the 30 Days of Terraform Challenge. Today’s demo covered something really important: understanding how we can build a two-tier architecture step by step while keeping everything secure and modular.
Sometimes things just make more sense visually, and for that, here’s a helpful video by Piyush Sachdeva, which explains everything including the demo clearly, with some troubleshooting steps you can follow along.
We’ll continue our journey tomorrow, exploring the next steps together. Until then, happy terraforming!


