“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
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
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
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
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
The high-level provisioning sequence becomes:
Terraform Apply
│
▼
Networking
│
▼
Database Infrastructure
│
▼
EC2 Creation
│
▼
EC2 Initialization
│
▼
user_data.sh
│
▼
Application Configuration
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...
}
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
]
}
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
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
})
}
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
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
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
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
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
The database Security Group is more restrictive:
Database Security Group
Inbound
MySQL :3306
Source: Application Security Group
Outbound
Required outbound traffic
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
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
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
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
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
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
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
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)