DEV Community

Cover image for Engineering a Secure Two Tier AWS Application with Terraform & Automated EC2 Provisioning
Adeje Oluwatobiloba
Adeje Oluwatobiloba

Posted on

Engineering a Secure Two Tier AWS Application with Terraform & Automated EC2 Provisioning

“Build once. Automate everything. Deploy with confidence.”

Cloud engineering is more than provisioning resources.

It is about designing infrastructure that is reproducible, secure, maintainable, and automated.

As part of my hands-on Cloud and DevOps journey, I designed and deployed EpicBook, a Node.js application on AWS using Terraform for Infrastructure as Code (IaC) and Bash automation through EC2 "user_data.sh".

This project brought together AWS networking, Terraform modules, Linux administration, application deployment, security, and automation into a single practical workflow.

Project Summary

EpicBook AWS 2-Tier Application Deployment

I designed and deployed a 2-tier Node.js application on AWS with a focus on:

  • Infrastructure as Code
  • Network segmentation
  • Database isolation
  • Security group-based access control
  • Automated server provisioning
  • Application deployment
  • Infrastructure dependencies
  • Linux administration

🛠️ Technology Stack

Area Technology
Cloud AWS
Infrastructure as Code Terraform
Compute Amazon EC2
Database Amazon RDS for MySQL
Networking Amazon VPC
Security AWS Security Groups
Operating System Linux
Automation Bash / EC2 User Data
Web Server Nginx
Application Node.js / EpicBook
Process Management PM2

Rather than manually provisioning resources through the AWS Management Console, I defined the infrastructure using Terraform and automated server configuration with Bash.


Why This Project Matters

The goal wasn't simply to deploy an application on AWS.

I wanted to demonstrate the ability to translate an application requirement into cloud infrastructure and then automate the deployment process.

The project demonstrates practical experience with:

Infrastructure as Code

Provisioning AWS infrastructure using reusable Terraform modules instead of manually creating resources.

Cloud Networking

Designing separate application and database network boundaries using public and private subnets.

Security

Restricting database access to the application Security Group instead of exposing MySQL directly to the internet.

Automation

Using EC2 "user_data.sh" to automatically configure the server, deploy the application, configure Nginx, and manage the Node.js process.

Terraform Module Design

Using module inputs and outputs to create clear interfaces between infrastructure components.

Linux & Application Operations

Using Linux, Nginx, Node.js, and PM2 to build and operate the application server.


Architecture Overview

EpicBook follows a traditional 2-tier architecture.

Application Tier

The application tier runs inside a public subnet and contains:

  • Amazon EC2
  • Nginx
  • Node.js
  • PM2

Database Tier

The database tier is isolated inside private subnets and contains:

  • Amazon RDS for MySQL
  • RDS DB Subnet Group
  • Two private database subnets

The entire architecture is contained within an Amazon VPC.

Network Design

The VPC uses the CIDR block:

10.0.0.0/16
Enter fullscreen mode Exit fullscreen mode

The network is divided into application and database boundaries:

VPC
10.0.0.0/16
│
├── Public Subnet
│   └── 10.0.1.0/24
│       └── EC2 / Application Tier
│
├── Private DB Subnet A
│   └── 10.0.2.0/28
│
└── Private DB Subnet B
    └── 10.0.3.0/28
        └── RDS MySQL
Enter fullscreen mode Exit fullscreen mode

This separation provides a clear security boundary between the application and database tiers.

The application server requires internet connectivity, while the database should remain inaccessible directly from the public internet.


Request Flow

When a user accesses EpicBook, traffic follows this path:

User
 │
 ▼
Internet
 │
 ▼
Internet Gateway
 │
 ▼
Public Subnet
 │
 ▼
EC2
 │
 ▼
Nginx :80
 │
 ▼
Node.js Application
 │
 │ TCP 3306
 ▼
RDS MySQL
Enter fullscreen mode Exit fullscreen mode

Nginx acts as the public-facing HTTP entry point and reverse proxy.

The Node.js application runs on its application port, while communication with MySQL occurs through the VPC network.


Building the Infrastructure with Terraform

One of the primary objectives of the project was to avoid manually creating infrastructure through the AWS Console.

Instead, I defined the infrastructure declaratively using Terraform.

The infrastructure was separated into logical modules:

terraform/
│
├── modules/
│   ├── networking/
│   ├── compute/
│   └── database/
│
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
Enter fullscreen mode Exit fullscreen mode

This provides a cleaner separation of responsibilities.

Networking Module

Responsible for:

  • VPC
  • Public subnet
  • Private database subnets
  • Route tables
  • Internet Gateway
  • Security Groups

Compute Module

Responsible for:

  • EC2 instance
  • Application subnet association
  • Application Security Group
  • EC2 bootstrap configuration

Database Module

Responsible for:

  • RDS DB Subnet Group
  • RDS MySQL
  • Database Security Group association

This modular approach makes the infrastructure easier to understand, maintain, and extend.


Terraform Module Dependencies & EC2 Bootstrap Ordering

One of the more interesting parts of this project was managing the dependency between the database module and compute module.

The application server needs database information during its configuration.

Since the EC2 instance executes "user_data.sh" during its initial boot process, I wanted the required database infrastructure to be provisioned before Terraform created the application server.

The dependency flow was designed as:

Networking Module
       │
       ├── VPC
       ├── Public Subnet
       ├── Private DB Subnets
       └── Security Groups
              │
              ▼
       Database Module
              │
              ├── DB Subnet Group
              └── RDS MySQL
                     │
                     ▼
              Compute Module
                     │
                     └── EC2
                          │
                          ▼
                    user_data.sh
Enter fullscreen mode Exit fullscreen mode

The high-level provisioning sequence becomes:

Terraform Apply
      │
      ▼
Networking
      │
      ▼
Database Infrastructure
      │
      ▼
EC2 Creation
      │
      ▼
EC2 Initialization
      │
      ▼
user_data.sh
      │
      ▼
Application Configuration
Enter fullscreen mode Exit fullscreen mode

Explicit Dependency with "depends_on"

In the root module, I used Terraform's "depends_on" meta-argument to explicitly establish a dependency between the compute and database modules:

module "database" {
  source = "./modules/database"

  vpc_id                = module.networking.vpc_id
  private_db_subnet_ids = module.networking.private_db_subnet_ids
  security_group_id     = module.networking.security_group_ids["db"]

  # Other database variables...
}
Enter fullscreen mode Exit fullscreen mode
module "compute" {
  source = "./modules/compute"

  vpc_id            = module.networking.vpc_id
  public_subnet_id  = module.networking.public_subnet_id
  security_group_id = module.networking.security_group_ids["app"]

  db_endpoint = module.database.db_endpoint

  # Other compute variables...

  depends_on = [
    module.database
  ]
}

Enter fullscreen mode Exit fullscreen mode

This tells Terraform that the compute module must wait for the database module's resources to be created before Terraform begins creating the compute resources.

The important distinction is that "depends_on" controls the Terraform provisioning dependency.

It does not directly control when "user_data.sh" executes.

Once Terraform creates the EC2 instance, AWS runs the user-data script during the instance's initialization process.

Therefore:

Database Module
      │
      │ Terraform dependency
      ▼
Compute Module
      │
      ▼
EC2 Created
      │
      ▼
EC2 Initialization
      │
      ▼
user_data.sh
Enter fullscreen mode Exit fullscreen mode

Passing the Database Endpoint to EC2

The database module exposes the RDS endpoint through a Terraform output.

The compute module receives that output:

db_endpoint = module.database.db_endpoint

The EC2 resource then passes the value into the bootstrap script using Terraform's "templatefile()" function:

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = var.instance_type

  subnet_id              = var.public_subnet_id
  vpc_security_group_ids = [var.security_group_id]

  user_data = templatefile("${path.module}/templates/user_data.sh", {
    db_endpoint = var.db_endpoint

    ## other values
  })
}
Enter fullscreen mode Exit fullscreen mode

The Bash script can then consume the database endpoint.

This creates a useful relationship between Terraform infrastructure and server configuration.

RDS
 │
 │ db_endpoint
 ▼
Terraform Output
 │
 ▼
Compute Module Input
 │
 ▼
templatefile()
 │
 ▼
user_data.sh
 │
 ▼
Node.js Application
Enter fullscreen mode Exit fullscreen mode

Implicit vs Explicit Dependencies

This project helped me understand an important Terraform concept: implicit and explicit dependencies are different mechanisms.

Implicit Dependency

Terraform automatically creates an implicit dependency when one resource or module references an attribute from another.

For example:

db_endpoint = module.database.db_endpoint

Terraform can see that the compute configuration consumes information produced by the database module.

Database Module
      │
      │ db_endpoint
      ▼
Compute Module
Enter fullscreen mode Exit fullscreen mode

Terraform can use this relationship when constructing its dependency graph.

Explicit Dependency

When a dependency exists logically but is not sufficiently represented through resource references, "depends_on" can be used:

depends_on = [
  module.database
]

This creates an explicit dependency:

Database Module
      
       depends_on
      
Compute Module
Enter fullscreen mode Exit fullscreen mode

For this project, I used "depends_on" deliberately to make the database → EC2 provisioning relationship explicit.


Resource Creation vs Application Readiness

One important lesson from this project is that Terraform resource creation order and application readiness are not exactly the same thing.

The dependency ensures:

RDS Infrastructure
      ↓
EC2 Creation
      ↓
user_data.sh
Enter fullscreen mode Exit fullscreen mode

But this does not mean the database will necessarily be ready to accept application connections at the exact moment the EC2 bootstrap script attempts the first connection.

The application still depends on:

  • Correct database endpoint
  • Correct credentials
  • Correct Security Group rules
  • Correct networking
  • RDS availability
  • Application configuration

Security Group Architecture

Security was an important consideration in the design.

The application Security Group controls access to the EC2 instance.

Application Security Group

Inbound

HTTP :80
Source: Internet

SSH :22
Source: My IP

Outbound
Required outbound traffic
Enter fullscreen mode Exit fullscreen mode

The database Security Group is more restrictive:

Database Security Group

Inbound

MySQL :3306
Source: Application Security Group

Outbound
Required outbound traffic
Enter fullscreen mode Exit fullscreen mode

The database is therefore not exposed to the entire internet on port "3306".

Instead, MySQL traffic is allowed from the application Security Group.

EC2
 │
 │ TCP 3306
 ▼
RDS MySQL
Enter fullscreen mode Exit fullscreen mode

This demonstrates a fundamental cloud security principle:

Allow only the communication that the application actually requires.

EC2 Automated Provisioning with "user_data.sh"

Provisioning an EC2 instance is only the beginning.

A fresh Linux server still needs to be configured before it can host the application.

To automate this process, I created an EC2 "user_data.sh" bootstrap script.

The objective was to transform:

Fresh EC2 Instance
        │
        ▼
    user_data.sh
        │
        ▼
Configured Application Server
Enter fullscreen mode Exit fullscreen mode

The bootstrap process performs tasks such as:

EC2 Launch
   │
   ▼
user_data.sh
   │
   ├── Update system
   ├── Install dependencies
   ├── Install Node.js
   ├── Configure application
   ├── Install Nginx
   ├── Configure reverse proxy
   ├── Install PM2
   └── Start application
Enter fullscreen mode Exit fullscreen mode

This significantly reduces manual SSH-based configuration.

Instead of manually configuring every new server, the instance can bootstrap itself automatically.


Nginx as a Reverse Proxy

The Node.js application runs on its application port, while Nginx provides the public HTTP entry point.

The request flow is:

Client
   │
   │ HTTP :80
   ▼
Nginx
   │
   │ Reverse Proxy
   ▼
Node.js Application
Enter fullscreen mode Exit fullscreen mode

Nginx receives the incoming request and forwards it to the EpicBook application.

PM2 manages the Node.js process.

The application stack therefore looks like:

Internet
    │
    ▼
  Nginx
    │
    ▼
 Node.js
    │
    ▼
   PM2
    │
    ▼
 RDS MySQL
Enter fullscreen mode Exit fullscreen mode

This gave me practical experience combining:

  • Linux administration
  • Nginx configuration
  • Reverse proxying
  • Node.js deployment
  • Process management
  • Application-to-database connectivity

Complete Deployment Workflow

The overall deployment can be represented as:

                 Terraform
                    │
                    ▼
             AWS Networking
                    │
        ┌───────────┼───────────┐
        ▼           ▼           ▼
     Public     Private DB   Security
     Subnet      Subnets      Groups
        │           │
        │           ▼
        │          RDS
        │           │
        └─────┬─────┘
              ▼
             EC2
              │
              ▼
        user_data.sh
              │
       ┌──────┼─────────┐
       ▼      ▼         ▼
     Linux  Node.js   Nginx
     Setup   Setup   Configuration
              │
              ▼
             PM2
              │
              ▼
         EpicBook
              │
              ▼
          RDS MySQL
Enter fullscreen mode Exit fullscreen mode

The goal was to make the deployment repeatable rather than dependent on manual configuration.


Challenges & Engineering Decisions

The most valuable part of the project was not simply creating AWS resources.

It was understanding how the individual components interact.

  • Designing Public and Private Subnets

I had to determine which components required internet-facing access and which should remain isolated.

The application server requires public connectivity, while the database should remain private.

  • Managing Terraform Dependencies

The database module depended on subnet information created by the networking module.

Terraform outputs and module inputs allowed me to establish that relationship without duplicating configuration.

I then used "depends_on" to explicitly represent the database → compute provisioning relationship.

  • Passing Infrastructure Information into Server Configuration

The EC2 bootstrap process required database information.

I used the RDS endpoint exposed by the database module and passed it through the compute module into "user_data.sh".

This demonstrated how Terraform can connect infrastructure provisioning with server configuration.

  • Controlling Database Access

Instead of allowing MySQL traffic from everywhere, I configured the database Security Group to accept traffic from the application Security Group.

This reduced the database's attack surface.

  • Automating Server Configuration

Instead of manually installing Node.js, Nginx, PM2, and application dependencies after every EC2 deployment, I moved these tasks into "user_data.sh".

This turned server configuration into a repeatable process.

  • Connecting Application Components

The application required several components to work together:

Internet
   ↓
Nginx
   ↓
Node.js
   ↓
MySQL
   ↓
RDS
Enter fullscreen mode Exit fullscreen mode

Troubleshooting these connections reinforced an important lesson:

Cloud infrastructure is a system of interconnected components, not a collection of isolated services.


Key Lessons Learned

This project strengthened my practical understanding of:

Infrastructure as Code
Infrastructure can be defined, version-controlled, reviewed, and reproduced using Terraform.

Terraform Modules
Infrastructure can be separated into smaller logical and reusable components.

Terraform Outputs
Outputs provide a clean mechanism for exposing infrastructure information to other modules.

Terraform Dependency Graph
Terraform uses resource references and explicit dependency declarations to determine relationships between infrastructure components.

AWS Networking
Public and private subnet separation provides a foundation for controlling application and database communication.

Security Groups
Security Groups can enforce communication boundaries between different application tiers.

Bash Automation
Bash scripts can eliminate repetitive server configuration tasks.

Linux Administration
Application deployment requires understanding packages, services, processes, permissions, networking, and configuration files.

Application Operations
Nginx and PM2 provide important operational components around a Node.js application.


🚀 Future Improvements

Although the project successfully demonstrates a functional 2-tier deployment, there are several areas I would explore in a more production-oriented implementation.

Availability & Scalability

  • Application Load Balancer
  • Auto Scaling Group
  • Multiple Availability Zones

Security

  • HTTPS with AWS Certificate Manager
  • AWS Secrets Manager
  • AWS Systems Manager Parameter Store
  • More restrictive IAM policies
  • AWS WAF

Observability

  • Amazon CloudWatch
  • Centralized application logging
  • Metrics and alarms
  • Infrastructure monitoring
    **
    DevOps & CI/CD**

  • GitHub Actions

  • Automated Terraform validation

  • Infrastructure security scanning

  • Automated application deployment
    **
    Infrastructure Management**

  • Terraform remote state using Amazon S3

  • State locking

  • Environment separation

  • Improved variable management

Database Reliability

  • RDS automated backups
  • Multi-AZ deployment
  • Improved database monitoring
  • Disaster recovery strategy

These improvements would introduce additional concepts around:

Availability · Scalability · Security · Observability · Reliability · Continuous Delivery


Final Thoughts

The biggest takeaway from EpicBook wasn't simply getting the application running on AWS.

It was learning how to bring together:

  • AWS + Terraform + Linux + Bash + Networking + Nginx + Node.js + MySQL into a repeatable deployment workflow.
  • Terraform handled infrastructure provisioning.
  • AWS provided the cloud infrastructure.
  • Linux provided the application environment.
  • Bash automated server configuration.
  • Nginx handled HTTP traffic and reverse proxying.
  • Node.js ran the application.
  • PM2 managed the application process.
  • RDS provided the managed database layer.

The project reinforced a principle I want to carry throughout my DevOps career:

Every project gives me another opportunity to turn theory into practical engineering experience.

P.S. This post is part of the DevOps Micro Internship (DMI) with Agentic AI — Cohort 3 — by Pravin Mishra. My graded progress is public: https://dmi.pravinmishra.com/s/Tobilee10.html · Start your DevOps journey: https://dmi.pravinmishra.com/?utm_source=student&utm_medium=ps-linkedin&utm_campaign=cohort3

Top comments (0)