Introduction
This guide walks you through migrating an existing Tomcat application to AWS ECS Fargate — covering everything from session management and DB migration to Auto Scaling, with a production-ready setup in mind.
Who this is for
- Developers migrating a Tomcat app to AWS
- Engineers new to ECS Fargate
Final Architecture
Internet
│
ALB (port 80/443)
│
ECS Fargate
├─ ElastiCache (Redis) ← session store
└─ RDS (PostgreSQL) ← persistent data
STEP 1: Create Security Groups
Create dedicated security groups for ALB, ECS, Redis, and RDS. Following the principle of least privilege, only allow what is strictly necessary.
# ALB — allow HTTP/HTTPS from the internet
aws ec2 create-security-group \
--group-name sg-alb \
--description "ALB SG" \
--vpc-id vpc-xxxxxxxx
aws ec2 authorize-security-group-ingress \
--group-id sg-alb --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress \
--group-id sg-alb --protocol tcp --port 443 --cidr 0.0.0.0/0
# ECS — allow 8080 from ALB only
aws ec2 create-security-group \
--group-name sg-ecs \
--description "ECS SG" \
--vpc-id vpc-xxxxxxxx
aws ec2 authorize-security-group-ingress \
--group-id sg-ecs --protocol tcp --port 8080 \
--source-group sg-alb
# Redis — allow 6379 from ECS only
aws ec2 create-security-group \
--group-name sg-redis \
--description "Redis SG" \
--vpc-id vpc-xxxxxxxx
aws ec2 authorize-security-group-ingress \
--group-id sg-redis --protocol tcp --port 6379 \
--source-group sg-ecs
# RDS — allow 5432 from ECS only
aws ec2 create-security-group \
--group-name sg-rds \
--description "RDS SG" \
--vpc-id vpc-xxxxxxxx
aws ec2 authorize-security-group-ingress \
--group-id sg-rds --protocol tcp --port 5432 \
--source-group sg-ecs
Here is a summary of the security group rules:
| Security Group | Inbound Rule |
|---|---|
| sg-alb | 0.0.0.0/0 → 80, 443 |
| sg-ecs | sg-alb → 8080 |
| sg-redis | sg-ecs → 6379 |
| sg-rds | sg-ecs → 5432 |
STEP 2: Store Credentials in Secrets Manager
Store passwords and other sensitive values in Secrets Manager so they never appear as plain text in your task definition.
aws secretsmanager create-secret \
--name myapp/db-password \
--secret-string "your-db-password"
aws secretsmanager create-secret \
--name myapp/db-user \
--secret-string "your-db-user"
aws secretsmanager create-secret \
--name myapp/redis-password \
--secret-string "your-redis-password"
STEP 3: Provision RDS
# Subnet group (private subnets)
aws rds create-db-subnet-group \
--db-subnet-group-name myapp-rds-subnet \
--db-subnet-group-description "myapp RDS subnet" \
--subnet-ids subnet-aaaaaaaa subnet-bbbbbbbb
# RDS instance
aws rds create-db-instance \
--db-instance-identifier myapp-db \
--db-instance-class db.t4g.small \
--engine postgres \
--engine-version 15.4 \
--master-username myapp \
--master-user-password "your-db-password" \
--db-name myapp \
--db-subnet-group-name myapp-rds-subnet \
--vpc-security-group-ids sg-rds \
--multi-az \
--storage-type gp3 \
--allocated-storage 20 \
--no-publicly-accessible
STEP 4: Provision ElastiCache (Redis)
Because Fargate replaces the underlying instance on every task restart, any session stored in memory will be lost. With multiple tasks running, a request routed to a different task will fail to find the session.
Externalizing sessions to ElastiCache (Redis) solves both problems.
# Subnet group
aws elasticache create-cache-subnet-group \
--cache-subnet-group-name myapp-redis-subnet \
--cache-subnet-group-description "myapp redis subnet" \
--subnet-ids subnet-aaaaaaaa subnet-bbbbbbbb
# Parameter group
aws elasticache create-cache-parameter-group \
--cache-parameter-group-name myapp-redis-params \
--cache-parameter-group-family redis7 \
--description "myapp redis params"
aws elasticache modify-cache-parameter-group \
--cache-parameter-group-name myapp-redis-params \
--parameter-name-values \
ParameterName=maxmemory-policy,ParameterValue=allkeys-lru \
ParameterName=timeout,ParameterValue=300
# Redis cluster (Multi-AZ, encrypted)
aws elasticache create-replication-group \
--replication-group-id myapp-redis \
--description "myapp session store" \
--engine redis \
--engine-version 7.0 \
--cache-node-type cache.t4g.medium \
--num-cache-clusters 2 \
--multi-az-enabled \
--automatic-failover-enabled \
--cache-subnet-group-name myapp-redis-subnet \
--cache-parameter-group-name myapp-redis-params \
--security-group-ids sg-redis \
--at-rest-encryption-enabled \
--transit-encryption-enabled \
--auth-token "your-redis-password"
Spring Session configuration
Add the dependencies to pom.xml:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
Configure application.yml:
spring:
session:
store-type: redis
timeout: 30m
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
password: ${REDIS_PASSWORD}
ssl:
enabled: true
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
STEP 5: Build Docker Images and Push to ECR
Dockerfile (application)
FROM tomcat:10.1-jdk17
RUN rm -rf /usr/local/tomcat/webapps/*
COPY target/myapp.war /usr/local/tomcat/webapps/ROOT.war
EXPOSE 8080
CMD ["catalina.sh", "run"]
Dockerfile.migration (Flyway)
FROM flyway/flyway:10-alpine
COPY db/migration /flyway/sql
COPY flyway.conf /flyway/conf/flyway.conf
ENTRYPOINT ["flyway", "migrate"]
flyway.conf:
flyway.url=jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
flyway.user=${DB_USER}
flyway.password=${DB_PASSWORD}
flyway.locations=filesystem:/flyway/sql
flyway.baselineOnMigrate=true
Push to ECR
# Create repositories
aws ecr create-repository --repository-name myapp
aws ecr create-repository --repository-name myapp-migration
# Login
aws ecr get-login-password --region ap-northeast-1 \
| docker login --username AWS --password-stdin \
<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com
# Application image
docker build -t myapp .
docker tag myapp:latest \
<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:latest
docker push \
<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:latest
# Migration image
docker build -f Dockerfile.migration -t myapp-migration .
docker tag myapp-migration:latest \
<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/myapp-migration:latest
docker push \
<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/myapp-migration:latest
STEP 6: Create CloudWatch Logs Groups
aws logs create-log-group --log-group-name /ecs/myapp/migration
aws logs create-log-group --log-group-name /ecs/myapp/tomcat
STEP 7: Create the IAM Execution Role
The ECS agent needs this role to pull images from ECR and fetch secrets from Secrets Manager.
aws iam create-role \
--role-name ecsTaskExecutionRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy \
--role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam attach-role-policy \
--role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/SecretsManagerReadWrite
STEP 8: Register the Task Definition
The key point here is dependsOn with condition: SUCCESS — Tomcat will not start until the migration container exits cleanly.
{
"family": "myapp-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::<account_id>:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "migration",
"image": "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/myapp-migration:latest",
"essential": false,
"secrets": [
{ "name": "DB_USER", "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:<account_id>:secret:myapp/db-user" },
{ "name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:<account_id>:secret:myapp/db-password" }
],
"environment": [
{ "name": "DB_HOST", "value": "myapp-db.xxxx.ap-northeast-1.rds.amazonaws.com" },
{ "name": "DB_NAME", "value": "myapp" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp/migration",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs"
}
}
},
{
"name": "tomcat",
"image": "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:latest",
"essential": true,
"dependsOn": [
{ "containerName": "migration", "condition": "SUCCESS" }
],
"portMappings": [
{ "containerPort": 8080, "protocol": "tcp" }
],
"environment": [
{ "name": "DB_HOST", "value": "myapp-db.xxxx.ap-northeast-1.rds.amazonaws.com" },
{ "name": "DB_NAME", "value": "myapp" },
{ "name": "REDIS_HOST", "value": "myapp-redis.xxxx.ng.0001.apne1.cache.amazonaws.com" },
{ "name": "REDIS_PORT", "value": "6379" },
{ "name": "TZ", "value": "Asia/Tokyo" }
],
"secrets": [
{ "name": "DB_USER", "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:<account_id>:secret:myapp/db-user" },
{ "name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:<account_id>:secret:myapp/db-password" },
{ "name": "REDIS_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:<account_id>:secret:myapp/redis-password" }
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 90
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp/tomcat",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
aws ecs register-task-definition \
--cli-input-json file://task-definition.json
Note: Setting
essential: falseon the migration container means the task keeps running after migration completes. Tomcat usescondition: SUCCESSto wait for a clean exit before starting.
STEP 9: Create the ALB
# Create ALB
aws elbv2 create-load-balancer \
--name myapp-alb \
--subnets subnet-aaaaaaaa subnet-bbbbbbbb \
--security-groups sg-alb \
--scheme internet-facing \
--type application
# Create target group
# Important: Fargate requires --target-type ip, not instance
aws elbv2 create-target-group \
--name myapp-tg \
--protocol HTTP \
--port 8080 \
--vpc-id vpc-xxxxxxxx \
--target-type ip \
--health-check-path /health \
--health-check-interval-seconds 30 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3
# Create listener
aws elbv2 create-listener \
--load-balancer-arn <alb-arn> \
--protocol HTTP \
--port 80 \
--default-actions Type=forward,TargetGroupArn=<tg-arn>
# Shorten deregistration delay (default 300s is too long)
aws elbv2 modify-target-group-attributes \
--target-group-arn <tg-arn> \
--attributes Key=deregistration_delay.timeout_seconds,Value=30
STEP 10: Create the ECS Cluster and Service
# Create cluster
aws ecs create-cluster --cluster-name myapp-cluster
# Create service
aws ecs create-service \
--cluster myapp-cluster \
--service-name myapp-service \
--task-definition myapp-task \
--desired-count 8 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[subnet-aaaaaaaa,subnet-bbbbbbbb],
securityGroups=[sg-ecs],
assignPublicIp=DISABLED
}" \
--load-balancers "targetGroupArn=<tg-arn>,containerName=tomcat,containerPort=8080" \
--deployment-configuration \
"minimumHealthyPercent=50,maximumPercent=200,deploymentCircuitBreaker={enable=true,rollback=true}"
Enabling deploymentCircuitBreaker means ECS automatically rolls back to the previous revision if a deployment fails repeatedly.
STEP 11: Configure Auto Scaling
Auto Scaling handles traffic spikes without manual intervention.
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/myapp-cluster/myapp-service \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 4 \
--max-capacity 12
aws application-autoscaling put-scaling-policy \
--policy-name cpu-tracking \
--service-namespace ecs \
--resource-id service/myapp-cluster/myapp-service \
--scalable-dimension ecs:service:DesiredCount \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 70.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleOutCooldown": 60,
"ScaleInCooldown": 300
}'
| Setting | Value | Reason |
|---|---|---|
| min-capacity | 4 | Baseline task count |
| max-capacity | 12 | Peak capacity |
| TargetValue | 70% | Scale out when CPU exceeds 70% |
| ScaleOutCooldown | 60s | React quickly to load spikes |
| ScaleInCooldown | 300s | Prevent aggressive scale-in |
Verify the Deployment
# Check running tasks
aws ecs list-tasks --cluster myapp-cluster
# Check service status
aws ecs describe-services \
--cluster myapp-cluster \
--services myapp-service \
--query 'services[0].{Status:status,Running:runningCount,Desired:desiredCount}'
# Get the ALB DNS name and open it in a browser
aws elbv2 describe-load-balancers \
--names myapp-alb \
--query 'LoadBalancers[0].DNSName'
Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Task exits immediately after start | Application startup error | Check CloudWatch Logs for the stack trace |
| Health check never passes | Tomcat takes too long to start | Set startPeriod to 90–120 seconds |
| Cannot pull image from ECR | Missing permissions on ecsTaskExecutionRole
|
Attach AmazonECSTaskExecutionRolePolicy
|
| Cannot connect to Redis | Security group misconfiguration | Verify sg-ecs → sg-redis on port 6379 |
| Tomcat does not start after migration | Missing dependsOn
|
Add condition: SUCCESS to the task definition |
| ALB returns 502 | Target type set to instance
|
Change to ip
|
Summary
| Step | What you do |
|---|---|
| 1 | Create security groups |
| 2 | Store credentials in Secrets Manager |
| 3 | Provision RDS |
| 4 | Provision ElastiCache |
| 5 | Build images and push to ECR |
| 6 | Create CloudWatch Logs groups |
| 7 | Create IAM execution role |
| 8 | Register task definition |
| 9 | Create ALB |
| 10 | Create ECS cluster and service |
| 11 | Configure Auto Scaling |
Fargate eliminates server management and hands off deployment and scaling to AWS. Setting up session management, DB migration, and Auto Scaling from the start dramatically reduces rework later.
























