Skip to content

Phase 5: Amazon ECS Fargate

This phase details the documentation and configurations I used to provision a production-grade, containerized deployment of my java-monolith-app on AWS ECS Fargate.

Key Architectural Decisions

As Step 5B of my overall DevOps implementation journey, I migrated the application from a containerless EC2 deployment (documented in Phase 4: AWS EC2 Auto Scaling) to a fully managed container orchestration platform using ECS Fargate.

Key decisions I made for this deployment:

  1. Serverless Compute (Fargate): Instead of managing the underlying EC2 instances, I chose AWS Fargate. This abstracts away infrastructure management, allowing me to focus entirely on container configuration, scaling, and application logic.
  2. Container Image Management (ECR): I migrated the deployment artifact strategy from S3 (used in the EC2 deployment) to Amazon Elastic Container Registry (ECR). The ECS Task Execution Role is securely configured to pull the Docker image at launch.
  3. High Availability: The ECS Service spans multiple private subnets and is fronted by an Application Load Balancer (ALB) located in the public subnets.
  4. Security & Routing: TLS termination is handled at the ALB using an ACM certificate, with external traffic routed securely via Cloudflare DNS.

Architecture Overview

Internet
Cloudflare DNS  →  bankapp.ibtisam-iq.com
Application Load Balancer          ← sg-alb  (port 80 → redirect 443 | port 443 → forward)
   ├── us-east-1a  (subnet-public-1a   10.0.0.0/26)    ── NAT Gateway
   └── us-east-1b  (subnet-public-1b   10.0.0.64/26)
ECS Service (desired 2 | Fargate)
   ├── Task — us-east-1a  (subnet-private-1a  10.0.0.128/26)   ← sg-ecs  (port 8000 from ALB)
   └── Task — us-east-1b  (subnet-private-1b  10.0.0.192/26)
Amazon RDS — MySQL 8.4                                          ← sg-rds  (port 3306 from ECS)
(subnet-private-1a | Single-AZ | Multi-AZ ready by design)

AWS Services

Service Role
VPC + Subnets Isolated network — public/private separation across 2 AZs
Internet Gateway Inbound internet access for public subnets
NAT Gateway Outbound internet for ECS Tasks in private subnets
Bastion Host Secure SSH jump server for RDS initialization
Security Groups Least-privilege layer-4 traffic control between all tiers
Amazon ECR Private container registry for the application image
Amazon RDS (MySQL 8.4) Managed relational database in private subnet
IAM Role (ecsTaskExecutionRole) Grants ECS permission to pull images and write logs
CloudWatch Logs Container stdout/stderr log storage via awslogs driver
ECS Cluster Logical namespace for the ECS Service and Tasks
Task Definition Container manifest — image, port, env vars, logging
ECS Service Maintains desired Task count, integrates with ALB
Application Load Balancer HTTP/HTTPS traffic distribution across ECS Tasks
ACM TLS certificate for bankapp.ibtisam-iq.com
Cloudflare DNS CNAME record pointing to the ALB

Bare-Metal vs ECS — Key Differences

Concern Bare-Metal (Step 5A) ECS Fargate (Step 5B)
Compute unit EC2 instance Fargate Task (container)
Scaling Auto Scaling Group ECS Service desired count
Bootstrap User data + systemd Task Definition + ECR image
Artifact storage S3 (JAR) ECR (Docker image)
IAM role EC2 instance profile ECS Task Execution Role
Target type instance ip
No. of private IPs One per EC2 instance One per Task (awsvpc ENI)
SSH access Via Bastion to EC2 Not available (serverless)

Stage 1 — Network & Security Infrastructure

Phase 1 — VPC, Subnets, and Routing

Environment Variables

Capture all resource IDs as shell variables to keep commands clean and reproducible across phases.

export REGION="us-east-1"
export AZ_A="us-east-1a"
export AZ_B="us-east-1b"
export PROJECT="java-monolith"
export ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)

VPC

VPC_ID=$(aws ec2 create-vpc \
  --cidr-block 10.0.0.0/24 \
  --region $REGION \
  --tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=$PROJECT-vpc}]" \
  --query 'Vpc.VpcId' --output text)

Subnets

Divide the /24 block (10.0.0.0/24) into four /26 ranges — two public and two private, one per AZ.

Subnet Name CIDR AZ Type
subnet-public-1a 10.0.0.0/26 us-east-1a Public
subnet-public-1b 10.0.0.64/26 us-east-1b Public
subnet-private-1a 10.0.0.128/26 us-east-1a Private
subnet-private-1b 10.0.0.192/26 us-east-1b Private
PUB_1A=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.0.0/26 --availability-zone $AZ_A \
  --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=$PROJECT-public-1a}]" \
  --query 'Subnet.SubnetId' --output text)

PUB_1B=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.0.64/26 --availability-zone $AZ_B \
  --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=$PROJECT-public-1b}]" \
  --query 'Subnet.SubnetId' --output text)

PRIV_1A=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.0.128/26 --availability-zone $AZ_A \
  --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=$PROJECT-private-1a}]" \
  --query 'Subnet.SubnetId' --output text)

PRIV_1B=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.0.192/26 --availability-zone $AZ_B \
  --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=$PROJECT-private-1b}]" \
  --query 'Subnet.SubnetId' --output text)

Internet Gateway

IGW_ID=$(aws ec2 create-internet-gateway \
  --tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=$PROJECT-igw}]" \
  --query 'InternetGateway.InternetGatewayId' --output text)

aws ec2 attach-internet-gateway \
  --internet-gateway-id $IGW_ID \
  --vpc-id $VPC_ID

NAT Gateway

Place the NAT Gateway in the public subnet (us-east-1a) so that ECS Tasks in private subnets can reach the internet for ECR image pulls — without being directly reachable from outside.

EIP=$(aws ec2 allocate-address --domain vpc \
  --query 'AllocationId' --region $REGION --output text)

NAT_ID=$(aws ec2 create-nat-gateway \
  --subnet-id $PUB_1A \
  --allocation-id $EIP \
  --tag-specifications "ResourceType=natgateway,Tags=[{Key=Name,Value=$PROJECT-nat}]" \
  --query 'NatGateway.NatGatewayId' --region $REGION --output text)

aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_ID --region $REGION

Route Tables

# Public Route Table → IGW
PUB_RT=$(aws ec2 create-route-table --vpc-id $VPC_ID \
  --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=$PROJECT-public-rt}]" \
  --query 'RouteTable.RouteTableId' --output text)

aws ec2 create-route --route-table-id $PUB_RT \
  --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID

aws ec2 associate-route-table --route-table-id $PUB_RT --subnet-id $PUB_1A
aws ec2 associate-route-table --route-table-id $PUB_RT --subnet-id $PUB_1B

# Private Route Table → NAT
PRIV_RT=$(aws ec2 create-route-table --vpc-id $VPC_ID \
  --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=$PROJECT-private-rt}]" \
  --query 'RouteTable.RouteTableId' --output text)

aws ec2 create-route --route-table-id $PRIV_RT \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id $NAT_ID

aws ec2 associate-route-table --route-table-id $PRIV_RT --subnet-id $PRIV_1A
aws ec2 associate-route-table --route-table-id $PRIV_RT --subnet-id $PRIV_1B

Bastion Host

Launch a small EC2 instance in the public subnet as the sole SSH entry point for RDS initialization. ECS Tasks are serverless — SSH is not available to them.

AMI_ID=$(aws ec2 describe-images \
  --owners 099720109477 \
  --filters "Name=name,Values=ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" \
            "Name=state,Values=available" \
  --query 'Images | sort_by(@, &CreationDate)[-1].ImageId' \
  --output text)

BASTION_SG=$(aws ec2 create-security-group \
  --group-name "$PROJECT-bastion-sg" \
  --description "Bastion Host SG" \
  --vpc-id $VPC_ID \
  --query 'GroupId' --output text)

MY_IP=$(curl -s https://checkip.amazonaws.com | tr -d '\n')

aws ec2 authorize-security-group-ingress \
  --group-id $BASTION_SG \
  --protocol tcp --port 22 \
  --cidr $MY_IP/32 --region $REGION

aws ec2 create-key-pair \
  --key-name "$PROJECT" \
  --region "$REGION" \
  --query 'KeyMaterial' \
  --output text > "${PROJECT}.pem"

chmod 400 "${PROJECT}.pem"

BASTION_ID=$(aws ec2 run-instances \
  --image-id $AMI_ID \
  --instance-type t2.micro \
  --subnet-id $PUB_1A \
  --security-group-ids $BASTION_SG \
  --associate-public-ip-address \
  --key-name $PROJECT \
  --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=$PROJECT-bastion}]" \
  --query 'Instances[0].InstanceId' --region $REGION --output text)

Phase 2 — Security Groups

Create three security groups with strict least-privilege rules. Each tier only accepts traffic from the tier directly in front of it.

# SG 1 — Application Load Balancer (open to internet on 80 and 443)
SG_ALB=$(aws ec2 create-security-group \
  --group-name "$PROJECT-alb-sg" \
  --description "ALB Security Group" \
  --vpc-id $VPC_ID --query 'GroupId' --output text)

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

# SG 2 — ECS Tasks (port 8000 from ALB only)
SG_ECS=$(aws ec2 create-security-group \
  --group-name "$PROJECT-ecs-sg" \
  --description "ECS Tasks Security Group" \
  --vpc-id $VPC_ID --query 'GroupId' --output text)

aws ec2 authorize-security-group-ingress --group-id $SG_ECS \
  --protocol tcp --port 8000 --source-group $SG_ALB

# SG 3 — RDS MySQL (port 3306 from ECS Tasks only)
SG_RDS=$(aws ec2 create-security-group \
  --group-name "$PROJECT-rds-sg" \
  --description "RDS Security Group" \
  --vpc-id $VPC_ID --query 'GroupId' --output text)

aws ec2 authorize-security-group-ingress --group-id $SG_RDS \
  --protocol tcp --port 3306 --source-group $SG_ECS

Traffic flow enforced by security group chaining:

Internet → ALB (80/443) → ECS Task (8000) → RDS (3306)
              ↑                  ↑                ↑
           sg-alb             sg-ecs           sg-rds

Note

ECS vs Bare-Metal security group difference In the bare-metal deployment, sg-app controlled EC2 instances.
Here sg-ecs controls ECS Tasks.
The rule logic is identical — only the name and the attached resource type differ.


Stage 2 — Data & Container Layer

Phase 3 — Database (Amazon RDS — MySQL 8.4)

RDS Subnet Group

Build the subnet group using both private subnets, following Multi-AZ best practice even though Single-AZ is selected for cost control.

aws rds create-db-subnet-group \
  --db-subnet-group-name "$PROJECT-db-subnet-group" \
  --db-subnet-group-description "Private subnets for RDS" \
  --subnet-ids $PRIV_1A $PRIV_1B

RDS Instance

aws rds create-db-instance \
  --db-instance-identifier "$PROJECT-db" \
  --db-instance-class db.t3.micro \
  --engine mysql \
  --engine-version 8.4.3 \
  --master-username admin \
  --master-user-password your_root_password_here \
  --db-name IbtisamIQbankappdb \
  --db-subnet-group-name "$PROJECT-db-subnet-group" \
  --vpc-security-group-ids $SG_RDS \
  --no-multi-az \
  --allocated-storage 20 \
  --no-publicly-accessible

Press q to exit the pager. The RDS instance continues creating in the background.

Database Initialization via Bastion

The RDS endpoint is not publicly accessible. Use the Bastion host to reach it. ECS Tasks cannot be SSH'd into, so the Bastion is the only initialization path.

Add a temporary inbound rule on sg-rds to allow port 3306 from the Bastion SG, then remove it immediately after initialization.

# On local machine — add port 3306 temporarily to RDS SG, sourced from Bastion SG
aws ec2 authorize-security-group-ingress \
  --group-id $SG_RDS \
  --protocol tcp --port 3306 \
  --source-group $BASTION_SG

# SSH into Bastion
ssh -i $PROJECT.pem ubuntu@<BASTION_PUBLIC_IP>

# On the Bastion host
sudo apt update -y && sudo apt install -y mysql-client

# Connect to RDS
mysql -h <RDS_ENDPOINT> -u admin -p

# Inside MySQL shell
CREATE DATABASE IbtisamIQbankappdb;
CREATE USER 'your_db_user'@'%' IDENTIFIED BY 'your_db_password';
GRANT ALL PRIVILEGES ON IbtisamIQbankappdb.* TO 'your_db_user'@'%';
FLUSH PRIVILEGES;
EXIT;

# On local machine — remove the temporary rule immediately after
aws ec2 revoke-security-group-ingress \
  --group-id $SG_RDS \
  --protocol tcp --port 3306 \
  --source-group $BASTION_SG

Warning

Remove the temporary rule without fail After removal, sg-rds returns to its permanent state: port 3306 open from sg-ecs only.
In the bare-metal deployment, the same pattern applied with sg-app as the permanent source.
Here sg-ecs replaces sg-app — the logic is identical.


Phase 4 — Container Image (Amazon ECR)

In the bare-metal deployment, the application JAR was stored in S3 and pulled at EC2 boot time. In the ECS deployment, the application runs as a Docker container. Store the image in ECR instead of S3.

Note

S3 is replaced by ECR Bare-metal: EC2 pulls JAR from S3 at boot via IAM instance profile.
ECS: Fargate Task pulls container image from ECR at launch via ecsTaskExecutionRole.

Create ECR Repository

aws ecr create-repository \
  --repository-name $PROJECT-app \
  --region $REGION

ECR_URI="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$PROJECT-app"

Authenticate Docker to ECR

aws ecr get-login-password --region $REGION \
  | docker login --username AWS --password-stdin \
    $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com

Build, Tag, and Push

# Build from the application repository root
docker build -t $PROJECT-app .

# Tag for ECR
docker tag $PROJECT-app:latest $ECR_URI:latest

# Push
docker push $ECR_URI:latest

Verify the image

aws ecr describe-images \
  --repository-name $PROJECT-app \
  --region $REGION \
  --query 'imageDetails[*].[imageTags,imagePushedAt]' \
  --output table

Phase 5 — IAM Execution Role, CloudWatch Log Group, and ECS Cluster

These three prerequisites must exist before registering the Task Definition.

ecsTaskExecutionRole

ECS uses this role to pull the image from ECR and send container logs to CloudWatch. Without it, the Task goes STOPPED immediately.

Note

ecsTaskExecutionRole vs EC2 instance profile In bare-metal, an EC2 instance profile with AmazonS3ReadOnlyAccess gave EC2 permission to pull the JAR from S3.
In ECS, ecsTaskExecutionRole with AmazonECSTaskExecutionRolePolicy gives ECS permission to pull the image from ECR and write logs to CloudWatch.
Both roles exist for the same reason: grant the compute layer access to its artifact source.

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

sleep 10

EXEC_ROLE_ARN=$(aws iam get-role \
  --role-name ecsTaskExecutionRole \
  --query 'Role.Arn' --output text)

CloudWatch Log Group

ECS does not auto-create log groups. Create the log group manually before registering the Task Definition — otherwise the Task fails at log driver initialization and stops immediately.

LOG_GROUP="/ecs/$PROJECT"

aws logs create-log-group \
  --log-group-name "$LOG_GROUP" \
  --region $REGION || true

aws logs describe-log-groups \
  --log-group-name-prefix "$LOG_GROUP" \
  --region $REGION

ECS Cluster

aws ecs create-cluster \
  --cluster-name $PROJECT \
  --region $REGION

Note

ECS Cluster vs Auto Scaling Group In bare-metal, the ASG was the compute backbone — it launched EC2 instances and replaced failed ones.
In ECS Fargate, the Cluster is just a logical namespace. ECS itself manages compute.
The ECS Service (not the Cluster) is the equivalent of the ASG — it maintains desired Task count and replaces failed Tasks.


Phase 6 — Task Definition

The Task Definition is the ECS equivalent of the Launch Template + User Data from the bare-metal deployment.

Note

Task Definition vs Launch Template Bare-metal Launch Template defined: AMI, instance type, security group, IAM profile, and user data (Java install, S3 pull, systemd service).
ECS Task Definition defines: container image, port, environment variables, IAM execution role, and log configuration.
Both describe what to run — not where to run it. Placement decisions (subnets, AZs) remain outside both.

RDS_ENDPOINT=$(aws rds describe-db-instances \
  --db-instance-identifier "$PROJECT-db" \
  --query 'DBInstances[0].Endpoint.Address' \
  --output text)

cat > taskdef.json <<EOF
{
  "family": "$PROJECT-task",
  "requiresCompatibilities": ["FARGATE"],
  "networkMode": "awsvpc",
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "$EXEC_ROLE_ARN",
  "containerDefinitions": [
    {
      "name": "$PROJECT-app",
      "image": "$ECR_URI:latest",
      "portMappings": [
        { "containerPort": 8000, "protocol": "tcp" }
      ],
      "environment": [
        { "name": "SPRING_APPLICATION_NAME",   "value": "IbtisamIQBankApp" },
        { "name": "SPRING_DATASOURCE_USERNAME", "value": "your_db_user" },
        { "name": "SPRING_DATASOURCE_PASSWORD", "value": "your_db_password" },
        { "name": "SPRING_DATASOURCE_URL",      "value": "jdbc:mysql://$RDS_ENDPOINT:3306/IbtisamIQbankappdb?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true" },
        { "name": "SERVER_PORT",                "value": "8000" }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "$LOG_GROUP",
          "awslogs-region": "$REGION",
          "awslogs-stream-prefix": "$PROJECT"
        }
      }
    }
  ]
}
EOF

aws ecs register-task-definition \
  --cli-input-json file://taskdef.json \
  --region $REGION

Note

Environment variables — same values, different mechanism Bare-metal: environment variables were written into the systemd unit file inside the user data script.
ECS: the same environment variables are defined in the environment array of the container definition.
The SPRING_DATASOURCE_URL value is identical in both — pointing to the same RDS endpoint.

Warning

networkMode is not VPC selection Setting networkMode: awsvpc enables each Task to receive its own ENI and private IP.
It does not choose which VPC, subnet, or security group to use.
Those are runtime decisions made when creating the ECS Service.


Stage 3 — Traffic, Scaling & Verification

Phase 7 — Target Group

Create the Target Group before the ALB because the listener references it by ARN.

TG_ARN=$(aws elbv2 create-target-group \
  --name "$PROJECT-tg" \
  --protocol HTTP \
  --port 8000 \
  --vpc-id $VPC_ID \
  --target-type ip \
  --ip-address-type ipv4 \
  --protocol-version HTTP1 \
  --health-check-protocol HTTP \
  --health-check-path /actuator/health \
  --health-check-interval-seconds 30 \
  --healthy-threshold-count 2 \
  --unhealthy-threshold-count 3 \
  --query 'TargetGroups[0].TargetGroupArn' --output text)

Note

Target type: ip, not instance Bare-metal Target Group used --target-type instance — the ALB forwarded traffic to EC2 instance IDs.
ECS Fargate Target Group must use --target-type ip — each Task gets its own ENI with a private IP, and the ALB forwards to that IP directly.
Using instance with Fargate causes targets to never register, and health checks to fail indefinitely.

Note

Health check path /actuator/health is exposed by Spring Boot Actuator and returns {"status":"UP"} when the application is ready.
This path is identical to what was used in the bare-metal deployment — no change required.


Phase 8 — Application Load Balancer, ACM & Cloudflare DNS

ALB

ALB_ARN=$(aws elbv2 create-load-balancer \
  --name "$PROJECT-alb" \
  --type application \
  --scheme internet-facing \
  --subnets $PUB_1A $PUB_1B \
  --security-groups $SG_ALB \
  --ip-address-type ipv4 \
  --query 'LoadBalancers[0].LoadBalancerArn' --output text)

ALB_DNS=$(aws elbv2 describe-load-balancers \
  --load-balancer-arns $ALB_ARN \
  --query 'LoadBalancers[0].DNSName' --output text)

aws elbv2 wait load-balancer-available \
  --load-balancer-arns $ALB_ARN

ACM Certificate

CERT_ARN=$(aws acm request-certificate \
  --domain-name "bankapp.ibtisam-iq.com" \
  --validation-method DNS \
  --query 'CertificateArn' --output text)

Retrieve the validation CNAME:

aws acm describe-certificate \
  --certificate-arn $CERT_ARN \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord' \
  --output table

Add the Name and Value fields as a CNAME record in Cloudflare → ibtisam-iq.com → DNS:

Field Value
Type CNAME
Name everything before .ibtisam-iq.com. in the Name field
Target the full Value field
Proxy status DNS only (grey cloud) — must not be proxied

Wait for validation to complete:

aws acm wait certificate-validated --certificate-arn $CERT_ARN

Warning

Certificate must be ISSUED before creating the HTTPS listener Attaching a PENDING_VALIDATION certificate to a listener causes UnsupportedCertificate error.

Listeners

# HTTP → HTTPS redirect (301)
aws elbv2 create-listener \
  --load-balancer-arn $ALB_ARN \
  --protocol HTTP --port 80 \
  --default-actions '[{
    "Type": "redirect",
    "RedirectConfig": {
      "Protocol": "HTTPS",
      "Port": "443",
      "StatusCode": "HTTP_301"
    }
  }]' \
  --no-cli-pager

# HTTPS → Target Group (forward)
aws elbv2 create-listener \
  --load-balancer-arn $ALB_ARN \
  --protocol HTTPS --port 443 \
  --certificates "CertificateArn=$CERT_ARN" \
  --default-actions "[{
    \"Type\": \"forward\",
    \"TargetGroupArn\": \"$TG_ARN\"
  }]" \
  --no-cli-pager

Cloudflare — DNS CNAME Record

Add a CNAME record in Cloudflare → ibtisam-iq.com → DNS pointing bankapp.ibtisam-iq.com to the ALB:

Field Value
Type CNAME
Name bankapp
Target value of $ALB_DNS
Proxy status DNS only (grey cloud)

Session Recovery

Restore all variables if the shell session was interrupted between steps:

ALB_ARN=$(aws elbv2 describe-load-balancers \
  --names "$PROJECT-alb" \
  --query 'LoadBalancers[0].LoadBalancerArn' --output text)

ALB_DNS=$(aws elbv2 describe-load-balancers \
  --load-balancer-arns $ALB_ARN \
  --query 'LoadBalancers[0].DNSName' --output text)

CERT_ARN=$(aws acm list-certificates \
  --query "CertificateSummaryList[?DomainName=='bankapp.ibtisam-iq.com'].CertificateArn" \
  --output text)

TG_ARN=$(aws elbv2 describe-target-groups \
  --names "$PROJECT-tg" \
  --query 'TargetGroups[0].TargetGroupArn' --output text)

EXEC_ROLE_ARN=$(aws iam get-role \
  --role-name ecsTaskExecutionRole \
  --query 'Role.Arn' --output text)

ECR_URI="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$PROJECT-app"
LOG_GROUP="/ecs/$PROJECT"

Phase 9 — ECS Service

The ECS Service is the functional equivalent of the Auto Scaling Group in the bare-metal deployment. It uses the Task Definition as a template, maintains the desired count of running Tasks, replaces failed Tasks, and integrates with the ALB Target Group.

Note

ECS Service vs Auto Scaling Group Bare-metal ASG: launched EC2 instances using a Launch Template, registered them with the Target Group, replaced failed instances automatically.
ECS Service: launches Fargate Tasks using a Task Definition, registers Task IPs with the Target Group (type ip), replaces stopped Tasks automatically.
Both use desired-count / desired-capacity = 2, minimum 2, across both private subnets.

aws ecs create-service \
  --cluster $PROJECT \
  --service-name $PROJECT-svc \
  --task-definition $PROJECT-task \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={
    subnets=[$PRIV_1A,$PRIV_1B],
    securityGroups=[$SG_ECS],
    assignPublicIp=DISABLED
  }" \
  --load-balancers "targetGroupArn=$TG_ARN,containerName=$PROJECT-app,containerPort=8000" \
  --health-check-grace-period 120 \
  --region $REGION

Note

assignPublicIp=DISABLED Tasks run in private subnets. All inbound traffic arrives through the ALB.
Tasks reach ECR and CloudWatch outbound via the NAT Gateway.
This mirrors the bare-metal design where EC2 instances were also in private subnets.

Note

health-check-grace-period Spring Boot takes time to start. Set the grace period to 120 seconds so the ALB does not mark Tasks unhealthy before the application finishes booting.
In bare-metal, the same issue was addressed with --health-check-grace-period 120 on the ASG.

Optional: run a one-off Task to validate before creating the Service

Use run-task to verify the Task Definition works correctly before committing to a Service.

aws ecs run-task \
  --cluster $PROJECT \
  --launch-type FARGATE \
  --task-definition $PROJECT-task \
  --network-configuration "awsvpcConfiguration={
    subnets=[$PUB_1A],
    securityGroups=[$SG_ECS],
    assignPublicIp=ENABLED
  }" \
  --count 1 \
  --region $REGION

Note

run-task for smoke testing This is the ECS equivalent of launching a single EC2 instance outside the ASG to test the user data script.
Place the Task in a public subnet with assignPublicIp=ENABLED to reach it directly.
Once verified, tear down the test Task and proceed with the Service in private subnets.


Phase 10 — Verification

ECS Service and Task Status

# Check Service status
aws ecs describe-services \
  --cluster $PROJECT \
  --services $PROJECT-svc \
  --query 'services[0].[status,runningCount,desiredCount,pendingCount]' \
  --output table

# List running Tasks
aws ecs list-tasks \
  --cluster $PROJECT \
  --service-name $PROJECT-svc \
  --query 'taskArns' \
  --output table

Expected: runningCount = 2, desiredCount = 2, pendingCount = 0.

Target Group Health

aws elbv2 describe-target-health \
  --target-group-arn $TG_ARN \
  --query 'TargetHealthDescriptions[*].[Target.Id,TargetHealth.State]' \
  --output table

Expected output:

----------------------------------
|    DescribeTargetHealth        |
+------------------+-------------+
|  10.0.0.134      |  healthy    |
|  10.0.0.196      |  healthy    |
+------------------+-------------+

Note

IP addresses instead of instance IDs Bare-metal Target Group showed EC2 instance IDs like i-0abc123def456.
ECS Fargate Target Group shows Task private IP addresses like 10.0.0.134.
Both confirm the same thing: targets are registered and passing health checks.

CloudWatch Logs

# List log streams
aws logs describe-log-streams \
  --log-group-name "$LOG_GROUP" \
  --order-by LastEventTime \
  --descending \
  --query 'logStreams[*].logStreamName' \
  --output table

# Tail a stream
aws logs get-log-events \
  --log-group-name "$LOG_GROUP" \
  --log-stream-name "<STREAM_NAME>" \
  --limit 50 \
  --output text

Application Endpoints

# HTTP — should return 301 redirect to HTTPS
curl -I http://bankapp.ibtisam-iq.com

# HTTPS — should return HTTP 200
curl -I https://bankapp.ibtisam-iq.com

# Spring Boot Actuator health check
curl https://bankapp.ibtisam-iq.com/actuator/health
# Expected: {"status":"UP"}

End-to-End Functional Test

Access the application via browser at https://bankapp.ibtisam-iq.com. Register a new account, verify login, and confirm transactional data is written to and read from RDS — confirming all three tiers (ALB → ECS Task → RDS) are operating correctly end to end.


Stage 4 — Troubleshooting

The same two issues that surfaced in the bare-metal deployment also apply here. Both root causes are in the application code, not the infrastructure — so the fixes are identical regardless of whether the compute layer is EC2 or ECS.

Issue 1 — ALB Health Checks Failing (Tasks Marked Unhealthy)

Symptom: ECS Service launches Tasks, but the Target Group marks them unhealthy. ECS enters a replacement loop.

Diagnosis: Check Task logs in CloudWatch first.

aws logs get-log-events \
  --log-group-name "$LOG_GROUP" \
  --log-stream-name "<STREAM_NAME>" \
  --limit 100 \
  --output text

If the application starts cleanly, the health check path itself is the issue. Spring Security intercepts unauthenticated requests to /actuator/health and redirects them to /login. The ALB receives a 302, follows it to /login, and marks the response as failed.

Fix: Update SecurityConfig.java to permit /actuator/health before the anyRequest().authenticated() rule. Rebuild the image, push the new tag to ECR, and force a new Service deployment.

# Force new deployment after pushing updated image
aws ecs update-service \
  --cluster $PROJECT \
  --service $PROJECT-svc \
  --force-new-deployment \
  --region $REGION

Note

Bare-metal vs ECS debug path Bare-metal: SSH'd via Bastion into the private EC2 instance and ran curl localhost:8000/actuator/health directly.
ECS Fargate: Tasks are serverless — no SSH. Reproduce the same test by running a temporary Task with assignPublicIp=ENABLED in a public subnet, or read CloudWatch logs directly.

Issue 2 — HTTPS Login Redirect Loop

Symptom: Application is reachable, but the login form produces an infinite redirect loop.

Root cause: The ALB terminates TLS and forwards plain HTTP to the Task on port 8000. Spring ignores the X-Forwarded-Proto: https header and generates http:// post-login redirects. The browser upgrades these to https://, invalidating the session cookie on every redirect.

Fix: Add three properties to application.properties and enable ALB sticky sessions.

server.forward-headers-strategy=native
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax
aws elbv2 modify-target-group-attributes \
  --target-group-arn $TG_ARN \
  --attributes \
    Key=stickiness.enabled,Value=true \
    Key=stickiness.type,Value=lb_cookie \
    Key=stickiness.lb_cookie.duration_seconds,Value=86400

Rebuild, push, and force a new Service deployment.

Note

Root cause is identical across both deployments This issue exists because the ALB terminates TLS in both bare-metal and ECS deployments.
The application fix and the sticky session configuration are the same — only the compute layer differs.


Stage Summary

Stage Phases Covers
Stage 1 — Network & Security Phase 1 → Phase 2 VPC, Subnets, IGW, NAT, Route Tables, Bastion, Security Groups
Stage 2 — Data & Container Phase 3 → Phase 6 RDS MySQL 8.4, ECR, ecsTaskExecutionRole, CloudWatch Log Group, ECS Cluster, Task Definition
Stage 3 — Traffic, Scaling & Verification Phase 7 → Phase 10 Target Group, ALB, ACM, Cloudflare DNS, ECS Service, Health Checks, End-to-End Test
Stage 4 — Troubleshooting ALB health check failure, HTTPS login redirect loop