Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AWS Two-Tier Message Board — Java Web Application

A Java Servlet/JSP web application deployed on AWS in a horizontally scalable, two-tier architecture: API Gateway fronts an Application Load Balancer that distributes traffic across an Auto Scaling Group of Tomcat EC2 instances, backed by a Multi-AZ RDS MySQL database — all within a VPC. No EC2 instance is reachable from the internet directly.


Table of Contents

  1. Architecture Overview
  2. Application Flow
  3. Project Structure
  4. Scalability Design
  5. Quick Deploy
  6. Manual AWS Setup
  7. Configuration Reference
  8. Demo Guide
  9. Troubleshooting
  10. Cleanup

Architecture Overview

Internet
   │
   │ HTTPS
   ▼
┌─────────────────────────────────────────┐
│         API Gateway (HTTP API)          │  ← Single stable endpoint
│  https://<id>.execute-api.<region>.com  │
└────────────────┬────────────────────────┘
                 │ VPC Link (private)
                 ▼
┌─────────────────────────────────────────┐  Public Subnets (10.0.1.0/24, 10.0.2.0/24)
│     Application Load Balancer (ALB)     │  AZ-a + AZ-b
│          internet-facing                │
└──────────┬──────────────────────────────┘
           │ HTTP :8080  (round-robin)
           ▼
┌──────────────────────────────────────────┐  Private Web Subnets (10.0.3.0/24, 10.0.4.0/24)
│       Auto Scaling Group (ASG)           │  AZ-a + AZ-b
│   ┌──────────────┐  ┌──────────────┐    │
│   │  Tomcat EC2  │  │  Tomcat EC2  │ …  │  Min: 2  Max: 6
│   │  (AZ-a)      │  │  (AZ-b)      │    │  Scale out: CPU > 70%
│   └──────────────┘  └──────────────┘    │  Scale in:  CPU < 30%
└──────────────────────┬───────────────────┘
                       │ JDBC :3306 (private IP)
                       ▼
┌──────────────────────────────────────────┐  Private DB Subnets (10.0.5.0/24, 10.0.6.0/24)
│          RDS MySQL 8.0 (Multi-AZ)        │  Primary: AZ-a  Standby: AZ-b
│        No public access                  │  Automatic failover < 60s
└──────────────────────────────────────────┘

Subnet layout

Subnet CIDR AZ Purpose
Public-1 10.0.1.0/24 a ALB node, NAT Gateway
Public-2 10.0.2.0/24 b ALB node, NAT Gateway
Private-Web-1 10.0.3.0/24 a Tomcat EC2 (ASG)
Private-Web-2 10.0.4.0/24 b Tomcat EC2 (ASG)
Private-DB-1 10.0.5.0/24 a RDS primary
Private-DB-2 10.0.6.0/24 b RDS standby

Security group chain

Internet → ALB-SG (:80)
           ALB-SG → Web-SG (:8080)   [ALB is the only source]
                    Web-SG → DB-SG (:3306)   [Web-SG is the only source]

No EC2 instance has a public IP. No database port is reachable from outside the VPC.


Application Flow

Request: View Messages (GET /messages)

Browser
  → API Gateway endpoint
  → VPC Link → ALB (picks least-loaded instance)
  → Tomcat: MessageServlet.doGet()
  → MessageDAO.getMessages(page, pageSize)   ← HikariCP pool: reuses warm connection
      → SELECT ... FROM messages ORDER BY created_at DESC LIMIT ? OFFSET ?
  → index.jsp renders paginated HTML
  → Response back through ALB → API Gateway → browser

Request: Post a Message (POST /post)

Browser
  → API Gateway → ALB → any Tomcat instance
  → PostMessageServlet.doPost()
  → Validate + truncate input
  → MessageDAO.addMessage(username, message)
      → INSERT INTO messages (username, message) VALUES (?, ?)
  → HTTP 302 → GET /messages  (Post-Redirect-Get, prevents duplicate submissions)

ALB Health Check (GET /health)

ALB (every 30s, per instance)
  → HealthCheckServlet.doGet()
  → Acquires connection from HikariCP pool → SELECT 1
  → 200 OK  → instance stays in rotation
  → 503      → ALB stops routing to this instance; ASG eventually replaces it

Component Responsibilities

Component File Responsibility
Message model/Message.java Data transfer object
MessageDAO dao/MessageDAO.java All SQL; accepts shared DataSource
DatabasePool DatabasePool.java HikariCP pool singleton; reads config from env vars
AppContextListener AppContextListener.java Initialises/closes the pool with Tomcat lifecycle
MessageServlet servlet/MessageServlet.java GET /messages — loads page, forwards to JSP
PostMessageServlet servlet/PostMessageServlet.java POST /post — validates, writes, redirects
HealthCheckServlet servlet/HealthCheckServlet.java GET /health — ALB target health probe
index.jsp webapp/index.jsp Renders form + paginated message list

Project Structure

java-ec2-sample-application/
├── deploy.sh                   # Unified deploy — local (Podman) and AWS modes
├── Dockerfile                  # Tomcat 9 image used by Podman Compose
├── compose.yml                 # Local dev: MySQL 8.0 + Tomcat (Podman Compose)
├── pom.xml
├── infrastructure/
│   ├── template.yml            # CloudFormation — complete scalable stack
│   ├── deploy.sh               # AWS-only deploy (called by root deploy.sh)
│   └── local/
│       └── init.sql            # MySQL schema — auto-runs in Podman on first start
└── src/main/
    ├── java/com/example/messageboard/
    │   ├── DatabasePool.java           # HikariCP pool (configurable size)
    │   ├── AppContextListener.java     # Pool lifecycle (startup/shutdown)
    │   ├── model/Message.java
    │   ├── dao/MessageDAO.java         # Paginated queries
    │   └── servlet/
    │       ├── MessageServlet.java
    │       ├── PostMessageServlet.java
    │       └── HealthCheckServlet.java
    └── webapp/
        ├── index.jsp
        ├── css/style.css
        └── WEB-INF/web.xml

Scalability Design

Connection pool budget

Each Tomcat instance has its own HikariCP pool. With horizontal scaling, total connections to RDS = instances × pool size. RDS max_connections is determined by the instance class:

RDS class max_connections Safe max instances (pool=10)
db.t3.micro ~66 5
db.t3.small ~150 12
db.t3.medium ~300 25
db.r6g.large ~1000+ 80+

Control this via the DB_POOL_MAX_SIZE env var (set per instance in the CloudFormation setenv.sh). The deploy script warns you if max_instances × pool_size approaches the RDS limit.

Horizontal scaling triggers

The ASG uses step scaling on CPUUtilization:

Condition Action
CPU > 70% for 2 min +1 instance (or +2 if CPU > 90%)
CPU < 30% for 5 min −1 instance (with 5 min cooldown)

The ALB health check (/health) drives instance replacement: if a Tomcat instance's DB connection fails, the health check returns 503, ALB stops routing to it, and the ASG's ELB health check eventually terminates and replaces it.

Statelessness

The application holds no session state in memory. Every request is self-contained (read from DB → render). Any instance can serve any request, which is what makes horizontal scaling safe.

RDS Multi-AZ failover

RDS Multi-AZ maintains a synchronous standby in a second AZ. On primary failure, AWS promotes the standby automatically (typically < 60 seconds). The JDBC URL points to the RDS endpoint DNS name, which AWS updates to the new primary — HikariCP's connection validation (SELECT 1) evicts stale connections and re-establishes to the new primary automatically.


Quick Deploy

A single script at the project root handles both local and AWS deployment.

./deploy.sh --help    # full usage reference

Local (Podman — no AWS account needed)

Prerequisites: Podman with podman-compose (or Podman 4.7+ compose plugin), Maven 3.x, Java 11+

./deploy.sh local          # build WAR → start MySQL + Tomcat in Podman
./deploy.sh local --stop   # stop containers (data is preserved)
./deploy.sh local --clean  # stop containers and delete all data volumes
./deploy.sh local --logs   # stream live Tomcat logs
./deploy.sh local --status # show container health
./deploy.sh local --open   # open the app in your browser

On first start the script builds the WAR, builds the container image, starts MySQL 8.0 and Tomcat 9 together, and polls the health endpoint until the stack is ready. The messages table is created automatically by infrastructure/local/init.sql.

App URL: http://localhost:8080/MessageBoard/messages


AWS (CloudFormation — full scalable stack)

Prerequisites: AWS CLI configured (aws configure), Maven 3.x, Java 11+

Choosing an S3 bucket name

Important: S3 bucket names are a global namespace shared across every AWS account in the world. Generic names like my-deploy-bucket or messageboard-bucket are almost certainly already taken by someone else and will produce a BucketAlreadyExists error.

Use your AWS account ID to guarantee uniqueness:

export BUCKET="messageboard-deploy-$(aws sts get-caller-identity --query Account --output text)"
echo $BUCKET   # e.g. messageboard-deploy-123456789012

Create an EC2 key pair (once per region)

An EC2 key pair is required for the CloudFormation stack. The key pair must exist in the same region before running the deploy. If you have no key pairs yet:

aws ec2 create-key-pair \
  --key-name messageboard-key \
  --region us-east-1 \
  --query KeyMaterial \
  --output text > ~/.ssh/messageboard-key.pem
chmod 600 ~/.ssh/messageboard-key.pem

To list key pairs that already exist in your region:

aws ec2 describe-key-pairs --region us-east-1 --query 'KeyPairs[*].KeyName' --output table

The deploy script validates the key pair before submitting the stack. If the name is wrong it exits immediately, lists available key pairs, and prints the create command — rather than waiting 15 minutes for CloudFormation to roll back.

Note: Key pairs are only needed for direct SSH. If you only use SSM Session Manager (the default in this stack), the key pair is still required by the CloudFormation parameter but the .pem file itself can be kept somewhere safe and never used.

First deploy

export BUCKET="messageboard-deploy-$(aws sts get-caller-identity --query Account --output text)"

./deploy.sh aws \
  --stack-name  MessageBoard \
  --bucket      $BUCKET \
  --key-pair    messageboard-key \
  --your-ip     $(curl -s https://checkip.amazonaws.com)/32 \
  --db-password 'YourStr0ngP@ss!' \
  --create-bucket

--create-bucket creates the bucket if it does not exist yet. It is safe to omit on subsequent runs once the bucket exists.

Redeploying after code changes

./deploy.sh aws \
  --stack-name  MessageBoard \
  --bucket      $BUCKET \
  --key-pair    messageboard-key \
  --your-ip     $(curl -s https://checkip.amazonaws.com)/32 \
  --db-password 'YourStr0ngP@ss!'

The script detects the stack already exists, runs update-stack, and triggers a rolling ASG instance refresh.

Optional flags

Flag Default Description
--region us-east-1 AWS region
--db-class db.t3.small RDS instance class
--web-type t3.micro EC2 instance type
--min 2 ASG minimum instances
--max 6 ASG maximum instances
--pool-size 10 HikariCP max pool per instance
--create-bucket off Create the S3 bucket if it does not exist
--skip-refresh off Skip ASG instance refresh after stack update

What the script does

  1. Validates prerequisites (AWS CLI, Maven, credentials)
  2. Validates --your-ip is an IPv4 CIDR — exits early if curl returned IPv6
  3. Checks the connection budget (max_instances × pool_size vs RDS max_connections)
  4. Runs mvn clean package
  5. Validates the S3 bucket name (distinguishes 403 = taken by another account from 404 = doesn't exist); creates it with --create-bucket if needed
  6. Uploads target/MessageBoard.war to S3
  7. Pre-flight validates the EC2 key pair exists in the target region — exits with the create command if not, rather than waiting 15 minutes for CloudFormation to roll back
  8. Detects ROLLBACK_COMPLETE — if a previous creation failed, prompts to delete the dead stack and proceeds cleanly
  9. Creates or updates the CloudFormation stack; on failure, prints the specific CloudFormation events that caused the rollback instead of the useless waiter error
  10. Prints the API Gateway URL and all stack outputs
  11. Triggers a rolling ASG instance refresh
  12. Prints SSM + SQL instructions for creating the messages table (first deploy only)

Stack creation takes ~15 minutes (RDS Multi-AZ provisioning dominates).

After first deploy — create the database table

The CloudFormation template creates the RDS database but not the messages table. The deploy script prints the exact steps, but in brief:

# 1. Get an instance ID
aws ec2 describe-instances \
  --filters 'Name=tag:Name,Values=MessageBoard-Web' \
            'Name=instance-state-name,Values=running' \
  --query 'Reservations[*].Instances[0].InstanceId' \
  --output text

# 2. Open a shell via SSM (no SSH key or bastion needed)
aws ssm start-session --target <instance-id>

# 3. Inside the session
mysql -h <rds-endpoint> -u javauser -p message_board
CREATE TABLE IF NOT EXISTS messages (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  username   VARCHAR(50)  NOT NULL,
  message    VARCHAR(500) NOT NULL,
  created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
EXIT;

Access the application

The script prints the API Gateway URL on completion:

https://<api-id>.execute-api.us-east-1.amazonaws.com/MessageBoard/messages

Manual AWS Setup

Use this if you prefer clicking through the console instead of CloudFormation.

Phase 1 — VPC and networking

  1. VPC: CIDR 10.0.0.0/16, DNS hostnames enabled
  2. 6 subnets across 2 AZs (see subnet layout table above)
  3. Internet Gateway → attach to VPC
  4. Public route table: 0.0.0.0/0 → IGW, associate Public-1 and Public-2
  5. NAT Gateways: one in each public subnet (requires Elastic IP each)
  6. Private web route tables: 0.0.0.0/0 → NAT-GW-1 for Private-Web-1, → NAT-GW-2 for Private-Web-2
  7. Private DB subnets need no route table — DB instances have no internet access

Phase 2 — Security groups

Create ALB-SG: inbound TCP 80 from 0.0.0.0/0

Create Web-SG: inbound TCP 8080 from ALB-SG (by SG ID), TCP 22 from your IP

Create DB-SG: inbound TCP 3306 from Web-SG (by SG ID)

Phase 3 — RDS MySQL

  1. EC2 Console → RDS → Create database
  2. Engine: MySQL 8.0, template: Production
  3. DB instance class: db.t3.small
  4. Enable Multi-AZ deployment
  5. DB name: message_board, master username: javauser
  6. VPC: your VPC, DB subnet group: create one covering Private-DB-1 and Private-DB-2
  7. Security group: DB-SG, no public access
  8. Note the endpoint hostname from the instance details after creation

Phase 4 — Database schema

SSH into any EC2 instance in the VPC (or use SSM Session Manager) and connect:

mysql -h <rds-endpoint> -u javauser -p message_board
CREATE TABLE IF NOT EXISTS messages (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    username   VARCHAR(50)  NOT NULL,
    message    VARCHAR(500) NOT NULL,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Phase 5 — ALB

  1. Create an Application Load Balancer, internet-facing, subnets: Public-1 + Public-2, SG: ALB-SG
  2. Create a Target Group: protocol HTTP, port 8080, health check path /MessageBoard/health
  3. Add a listener on port 80 → forward to the target group

Phase 6 — Launch Template and ASG

Create a Launch Template with:

  • AMI: Amazon Linux 2023 (latest)
  • Instance type: t3.micro
  • IAM instance profile: role with s3:GetObject on your WAR bucket + AmazonSSMManagedInstanceCore
  • Security group: Web-SG
  • UserData: see infrastructure/template.yml UserData section for the full install script

Create an Auto Scaling Group:

  • Launch template: above
  • Subnets: Private-Web-1 + Private-Web-2
  • Attach to target group
  • Health check type: ELB, grace period: 120s
  • Min: 2, Max: 6, Desired: 2
  • Add step scaling policies on CPUUtilization (see CloudFormation template for thresholds)

Phase 7 — API Gateway

  1. API Gateway console → Create API → HTTP API
  2. Add integration: ALB, select your ALB listener
  3. Create a VPC Link in Private-Web subnets
  4. Route: ANY /{proxy+} → integration
  5. Stage: $default with auto-deploy

Configuration Reference

All configuration is passed to Tomcat instances via environment variables set in /opt/tomcat/bin/setenv.sh (written by the CloudFormation UserData).

Variable Default Description
DB_HOST (RDS endpoint from CFN) RDS endpoint DNS name
DB_PORT 3306 MySQL port
DB_NAME message_board Database name
DB_USER javauser MySQL user
DB_PASS (parameter) MySQL password
DB_POOL_MAX_SIZE 10 HikariCP max connections per instance
DB_POOL_MIN_IDLE 2 HikariCP minimum idle connections

Connection budget formula: ensure MaxWebInstances × DB_POOL_MAX_SIZE ≤ RDS max_connections × 0.9 (leave 10% headroom for admin connections).


How the Application Connects to AWS

JDBC → RDS endpoint

DatabasePool.java reads DB_HOST from the environment. In the CloudFormation stack this is injected as ${RDSInstance.Endpoint.Address} — the DNS name RDS provides. On Multi-AZ failover, AWS updates this DNS record to point to the promoted standby. HikariCP's SELECT 1 validation query detects broken connections and re-establishes them to the new primary.

ALB health checks → HealthCheckServlet

The ALB probes GET /MessageBoard/health every 30 seconds per instance. HealthCheckServlet acquires a real connection from the pool and runs SELECT 1. If the DB is unreachable (network partition, RDS failover in progress), the servlet returns 503 and the ALB removes the instance from rotation within 3 failed checks (90 seconds).

API Gateway VPC Link

The VPC Link is a managed network interface that API Gateway projects into your private web subnets. Requests arrive at the API Gateway public endpoint, travel through the VPC Link (private traffic, no internet), hit the ALB, and are distributed to Tomcat instances. External clients only ever see the API Gateway hostname — the ALB DNS name and EC2 private IPs are never exposed.

ASG instance replacement

When the CloudFormation stack is updated or the deploy script re-runs, it triggers an instance refresh on the ASG with MinHealthyPercentage: 50. The ASG replaces instances in batches: launches a new one (which pulls the latest WAR from S3 via UserData), waits for the ALB health check to pass, then terminates an old one. This gives zero-downtime rolling deployments.

Verifying the security boundary in the console

  • EC2 → Instances → MySQL/Tomcat server: confirm "Public IPv4 address" is blank for all instances
  • RDS → Databases → messageboard-db: confirm "Publicly accessible: No"
  • EC2 → Security Groups → DB-SG → Inbound rules: source should be the SG ID of Web-SG, not a CIDR range
  • API Gateway → VPCs links: confirm the link is associated with private web subnets

Demo Guide

See docs/demo.md for a full walkthrough — what to show, what to say, and how to demonstrate each architectural feature live.

Quick reference — key demo moves:

Move What it shows
Refresh the page multiple times Hostname in the hero badge changes → ALB is round-robining across instances
Open in two tabs simultaneously Same message list despite different hostnames → stateless compute + shared RDS
Post a message, watch the redirect Post-Redirect-Get pattern; row appears for all instances immediately
Open /MessageBoard/health directly Health check endpoint that tests the DB; ALB uses this to remove broken instances
Run parallel curl requests, refresh Pool bar (red/green) shifts under load → HikariCP managing connections per instance
Terminate an EC2 instance in console ASG replaces it automatically; app stays up on the other instance

Troubleshooting

EC2 key pair not found

If you see:

✗  Key pair 'my-keypair' not found in us-east-1.

the key pair name is wrong or doesn't exist in that region. The script lists available key pairs and prints the create command. To create one manually:

aws ec2 create-key-pair \
  --key-name messageboard-key \
  --region us-east-1 \
  --query KeyMaterial \
  --output text > ~/.ssh/messageboard-key.pem
chmod 600 ~/.ssh/messageboard-key.pem

Then re-run with --key-pair messageboard-key.

Stack stuck in ROLLBACK_COMPLETE

A stack in ROLLBACK_COMPLETE is a dead stack — CloudFormation will not accept a new create-stack or update-stack over it. The deploy script detects this automatically and prompts you to delete it before proceeding. To do it manually:

aws cloudformation delete-stack --stack-name MessageBoard --region us-east-1
aws cloudformation wait stack-delete-complete --stack-name MessageBoard --region us-east-1

Then re-run the deploy command. Common causes of rollback: key pair doesn't exist, RDS parameter group issue, or hitting a service quota limit.

CloudFormation rollback with no clear reason

The deploy script automatically prints the failed resource events when a stack rolls back:

⚠  Stack operation failed. CloudFormation failure events:
--------------------------------------------------------------
| Timestamp  | LogicalResourceId | ResourceType | Reason   |
--------------------------------------------------------------

To check manually:

aws cloudformation describe-stack-events \
  --stack-name MessageBoard \
  --region us-east-1 \
  --query 'StackEvents[?contains(ResourceStatus,`FAILED`)].[Timestamp,LogicalResourceId,ResourceStatusReason]' \
  --output table

Or in the console: CloudFormation → MessageBoard → Events tab — look for rows with a red status.

S3 BucketAlreadyExists error

S3 bucket names are a global namespace shared across every AWS account. If you see:

An error occurred (BucketAlreadyExists) when calling the CreateBucket operation

the name is taken by a different account — you cannot use or claim it. Pick a name unique to you:

# Append your AWS account ID — guaranteed unique
export BUCKET="messageboard-deploy-$(aws sts get-caller-identity --query Account --output text)"
./deploy.sh aws --bucket $BUCKET --create-bucket ...

If you see BucketAlreadyOwnedByYou instead, the bucket already exists in your account — omit --create-bucket and proceed normally.

--your-ip rejected — IPv6 address

If you see:

✗  --your-ip '2601:...' is not a valid IPv4 CIDR

curl resolved over IPv6. Use the IPv4-only endpoint:

--your-ip $(curl -s https://checkip.amazonaws.com)/32

502 Bad Gateway from API Gateway

The VPC Link or ALB is not reaching Tomcat. Check:

  • aws elbv2 describe-target-health --target-group-arn <arn> — are targets healthy?
  • Tail Tomcat logs: SSM Session Manager into an instance → sudo journalctl -u tomcat -f
  • Confirm Web-SG allows TCP 8080 from ALB-SG

Health check failing (/health returning 503)

DB connection is down:

  • RDS may be in a failover — check RDS Events in the console; wait up to 60 seconds
  • Verify DB_HOST in /opt/tomcat/bin/setenv.sh matches the current RDS endpoint
  • From the instance: mysql -h $DB_HOST -u $DB_USER -p$DB_PASS $DB_NAME -e "SELECT 1"

Instances not scaling out

  • Check CloudWatch alarms: CloudWatch → Alarms — are they in ALARM state?
  • Confirm ASG has not hit MaxSize
  • Check ASG activity history: EC2 → Auto Scaling Groups → Activity tab

New WAR not deployed after redeploy

The instance refresh may still be in progress:

aws autoscaling describe-instance-refreshes \
  --auto-scaling-group-name MessageBoard-ASG

Check Status — if InProgress, wait for it to complete. If Failed, check the ASG activity log.

Cannot SSH into Tomcat instances

Instances are in private subnets with no public IP. Use SSM Session Manager:

aws ssm start-session --target <instance-id>

Or SSH via a bastion in a public subnet (not provisioned by this stack — add one if needed).


Cleanup

Local

./deploy.sh local --clean    # stop containers and delete the MySQL data volume

AWS

# Interactive teardown — prompts you to type the stack name to confirm
./deploy.sh aws --teardown --stack-name MessageBoard

# Or directly via the AWS CLI
aws cloudformation delete-stack --stack-name MessageBoard --region us-east-1

All resources are deleted. The RDS instance takes a final snapshot before deletion (DeletionPolicy: Snapshot in the CloudFormation template) so your data is not permanently lost.

To also remove the S3 bucket after the stack is gone:

export BUCKET="messageboard-deploy-$(aws sts get-caller-identity --query Account --output text)"
aws s3 rm s3://$BUCKET/MessageBoard.war
aws s3 rb s3://$BUCKET

About

Small practice web application in java used to just practice ec2 aws application development

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages