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.
- Architecture Overview
- Application Flow
- Project Structure
- Scalability Design
- Quick Deploy
- Manual AWS Setup
- Configuration Reference
- Demo Guide
- Troubleshooting
- Cleanup
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 | 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 |
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.
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
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 (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 | 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 |
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
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.
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.
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 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.
A single script at the project root handles both local and AWS deployment.
./deploy.sh --help # full usage referencePrerequisites: 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 browserOn 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
Prerequisites: AWS CLI configured (aws configure), Maven 3.x, Java 11+
Important: S3 bucket names are a global namespace shared across every AWS account in the world. Generic names like
my-deploy-bucketormessageboard-bucketare almost certainly already taken by someone else and will produce aBucketAlreadyExistserror.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
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.pemTo list key pairs that already exist in your region:
aws ec2 describe-key-pairs --region us-east-1 --query 'KeyPairs[*].KeyName' --output tableThe 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
.pemfile itself can be kept somewhere safe and never used.
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.
./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.
| 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 |
- Validates prerequisites (AWS CLI, Maven, credentials)
- Validates
--your-ipis an IPv4 CIDR — exits early ifcurlreturned IPv6 - Checks the connection budget (
max_instances × pool_sizevs RDSmax_connections) - Runs
mvn clean package - Validates the S3 bucket name (distinguishes 403 = taken by another account from 404 = doesn't exist); creates it with
--create-bucketif needed - Uploads
target/MessageBoard.warto S3 - 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
- Detects
ROLLBACK_COMPLETE— if a previous creation failed, prompts to delete the dead stack and proceeds cleanly - Creates or updates the CloudFormation stack; on failure, prints the specific CloudFormation events that caused the rollback instead of the useless waiter error
- Prints the API Gateway URL and all stack outputs
- Triggers a rolling ASG instance refresh
- Prints SSM + SQL instructions for creating the
messagestable (first deploy only)
Stack creation takes ~15 minutes (RDS Multi-AZ provisioning dominates).
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_boardCREATE 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;The script prints the API Gateway URL on completion:
https://<api-id>.execute-api.us-east-1.amazonaws.com/MessageBoard/messages
Use this if you prefer clicking through the console instead of CloudFormation.
- VPC: CIDR
10.0.0.0/16, DNS hostnames enabled - 6 subnets across 2 AZs (see subnet layout table above)
- Internet Gateway → attach to VPC
- Public route table:
0.0.0.0/0 → IGW, associate Public-1 and Public-2 - NAT Gateways: one in each public subnet (requires Elastic IP each)
- Private web route tables:
0.0.0.0/0 → NAT-GW-1for Private-Web-1,→ NAT-GW-2for Private-Web-2 - Private DB subnets need no route table — DB instances have no internet access
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)
- EC2 Console → RDS → Create database
- Engine: MySQL 8.0, template: Production
- DB instance class:
db.t3.small - Enable Multi-AZ deployment
- DB name:
message_board, master username:javauser - VPC: your VPC, DB subnet group: create one covering Private-DB-1 and Private-DB-2
- Security group: DB-SG, no public access
- Note the endpoint hostname from the instance details after creation
SSH into any EC2 instance in the VPC (or use SSM Session Manager) and connect:
mysql -h <rds-endpoint> -u javauser -p message_boardCREATE 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
);- Create an Application Load Balancer, internet-facing, subnets: Public-1 + Public-2, SG: ALB-SG
- Create a Target Group: protocol HTTP, port 8080, health check path
/MessageBoard/health - Add a listener on port 80 → forward to the target group
Create a Launch Template with:
- AMI: Amazon Linux 2023 (latest)
- Instance type:
t3.micro - IAM instance profile: role with
s3:GetObjecton your WAR bucket +AmazonSSMManagedInstanceCore - Security group: Web-SG
- UserData: see
infrastructure/template.ymlUserData 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)
- API Gateway console → Create API → HTTP API
- Add integration: ALB, select your ALB listener
- Create a VPC Link in Private-Web subnets
- Route:
ANY /{proxy+}→ integration - Stage:
$defaultwith auto-deploy
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).
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.
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).
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.
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.
- 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
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 |
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.pemThen re-run with --key-pair messageboard-key.
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-1Then re-run the deploy command. Common causes of rollback: key pair doesn't exist, RDS parameter group issue, or hitting a service quota limit.
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 tableOr in the console: CloudFormation → MessageBoard → Events tab — look for rows with a red status.
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.
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)/32The 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
DB connection is down:
- RDS may be in a failover — check RDS Events in the console; wait up to 60 seconds
- Verify
DB_HOSTin/opt/tomcat/bin/setenv.shmatches the current RDS endpoint - From the instance:
mysql -h $DB_HOST -u $DB_USER -p$DB_PASS $DB_NAME -e "SELECT 1"
- 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
The instance refresh may still be in progress:
aws autoscaling describe-instance-refreshes \
--auto-scaling-group-name MessageBoard-ASGCheck Status — if InProgress, wait for it to complete. If Failed, check the ASG activity log.
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).
./deploy.sh local --clean # stop containers and delete the MySQL data volume# 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-1All 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