Deploy Spring Boot to AWS ECS Fargate — End to End
Containerize a Spring Boot service and ship it to AWS ECS Fargate behind an Application Load Balancer.
The Decoupling from Elastic Beanstalk Monoliths
For years, many Java shops treated AWS Elastic Beanstalk as the default landing zone for Spring Boot. It was the safe choice: a wrapper around EC2 that handled autoscaling and load balancing. But as heap requirements climbed and startup times ballooned, the limitations of Beanstalk’s "dark magic" scripts in /opt/elasticbeanstalk/hooks/ became a liability. We found ourselves fighting with ebextensions just to get a specific version of Corretto installed or to tune G1GC parameters.
The move to ECS Fargate isn't just about moving from VMs to containers; it’s about moving from an opaque, stateful deployment model to a declarative, immutable infrastructure. In our legacy setup, a deployment took 8 minutes because the instance had to be provisioned, updated via yum, and then the fat JAR had to be executed. With a properly tuned Fargate task definition and a containerized Spring Boot image using layered jars, we cut that cold-start-to-service-healthy time down to under 120 seconds.
The migration follows a strict shift: moving environmental configuration out of .ebextensions and into Task Definitions, and shifting the load balancer logic from instance-based target groups to IP-based target groups.
Phase 1: Dockerizing for Layered Efficiency
The first mistake most engineers make when containerizing Spring Boot is a naive COPY target/*.jar app.jar. This creates a massive single layer that forces the Docker daemon to upload 150MB+ every time a single line of business logic changes. Given that the Spring Framework libraries rarely change, we must use the Spring Boot Layered JAR feature introduced in 2.3.
By extracting layers, we ensure that the dependencies and spring-boot-loader layers stay cached on the ECR (Elastic Container Registry) nodes. Only the application layer—usually less than 1MB—gets pushed during a typical CI/CD run.
```dockerfile
# Build stage
FROM amazoncorretto:17-al2023-jdk as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract# Runtime stage FROM amazoncorretto:17-al2023-headless WORKDIR application COPY --from=builder application/dependencies/ ./ COPY --from=builder application/spring-boot-loader/ ./ COPY --from=builder application/snapshot-dependencies/ ./ COPY --from=builder application/application/ ./
# Tune for Cgroup aware JVM ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"] ```
Rollback Criteria: If the docker build process exceeds 5 minutes on a standard GitHub Actions runner or if the final image size exceeds 400MB (indicative of unoptimized base images or leaking build artifacts), the migration halts. We verify the layers using docker history [image_id] to ensure the application layer is at the top.
Phase 2: Orchestrating the Networking Topology
In the Beanstalk world, the ALB (Application Load Balancer) often felt like a black box. In ECS Fargate, you must be explicit. Because Fargate tasks use the awsvpc network mode, every task gets its own Elastic Network Interface (ENI) and a private IP address within your VPC subnets.
We move away from instance-level security groups to a "Target Group per Service" model. The ALB sits in public subnets, while the Fargate tasks live in private subnets, reachable only via the ALB on the application port (typically 8080).
[ Internet ]
|
[ ALB (Public Subnets) ]
| (Port 80 -> Port 8080)
|
v
[ ECS Service (Private Subnets) ]
|-- [ Task 1 (10.0.1.45) ]
|-- [ Task 2 (10.0.1.92) ]
v
[ RDS / ElastiCache ]
The security group for the Fargate tasks must strictly allow ingress on 8080 only from the Security Group ID of the ALB. We found that manually managing these via the AWS Console led to "Security Group bloat." We moved this to Terraform/OpenTofu.
Rollback Criteria: A "Connectivity Smoke Test" is performed using a temporary Fargate task running curlimages/curl. We attempt to hit the Spring Boot health check endpoint /actuator/health at the private IP. If the connection times out, it indicates a Routing Table or Security Group mismatch. If we see a 503, the ALB target group is not correctly configured for IP-mode.
Phase 3: Task Definition and Resource Rightsizing
Spring Boot is notorious for its memory appetite during the ApplicationContext refresh phase. If you under-provision a Fargate task, the JVM will encounter an OOMKill before it even finishes the bean initialization.
For a standard microservice with Hibernate and roughly 50-100 beans, we found that 2 vCPUs and 4GB of RAM is the "Goldilocks" zone. While it costs more than the smallest 0.5 vCPU / 1GB RAM tier, it prevents the CPU throttling that often causes LivenessProbe failures during startup.
One specific, hard-won lesson: The Graceful Shutdown. ECS sends a SIGTERM to your container, waits for a stopTimeout (default 30s), and then sends SIGKILL. If your Spring Boot app is halfway through processing a heavy batch or a long-running REST request, SIGKILL will drop the connection. We must set server.shutdown=graceful in application.properties and ensure the ECS stopTimeout is set to 40 seconds to allow the JVM to drain connections.
# Example Task Definition Snippet (JSON)
{
"containerDefinitions": [
{
"name": "api-service",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/api-service:latest",
"cpu": 2048,
"memory": 4096,
"portMappings": [
{ "containerPort": 8080, "protocol": "tcp" }
],
"environment": [
{ "name": "SPRING_PROFILES_ACTIVE", "value": "prod" },
{ "name": "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE", "value": "health,metrics,prometheus" }
],
"stopTimeout": 40,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/api-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
Rollback Criteria: If the task fails to reach a RUNNING state within 180 seconds, or if the dmesg logs show OOMKill events, we immediately revert to the previous task definition revision. We monitor the CPUUtilization during the first 60 seconds of startup; if it stays at 100% for the entire duration, we increase the vCPU allocation.
Phase 4: Validating Deployment Patterns
With ECS, we finally get native Blue/Green deployments via AWS CodeDeploy. In our old Beanstalk setup, "Rolling with Additional Batch" deployments often left the cluster in an inconsistent state if a single instance failed. CodeDeploy on ECS allows us to shift traffic percentage-wise.
We implement a "Canary 10% 5 Minutes" strategy. The ALB creates a second listener (e.g., port 8443) for the "Green" (new) version. Our CI/CD pipeline runs integration tests against the green listener. If they pass, CodeDeploy begins shifting 10% of the production traffic from the "Blue" (old) target group to the "Green" one.
A critical metric we track during this shift is the HTTPCode_Target_5XX_Count. In a recent peak-load event, we observed that our P99 latency dropped from 480ms to 90ms simply because Fargate’s underlying Nitro system handled the I/O multiplexing of our high-throughput logging (via Logback's AWSLogs appender) better than the older t3.medium EC2 instances used in Beanstalk.
Rollback Criteria: During the 5-minute canary window, if the 5XX error rate increases by more than 1% over the baseline, CodeDeploy triggers an automatic rollback. All traffic is instantly rerouted to the "Blue" target group, and the "Green" tasks are terminated.
Operational Realities and Performance Tuning
Deploying the service is only half the battle. Java’s behavior inside a container is influenced heavily by the MaxRAMPercentage flag. Without it, older JVMs might see the host's total memory instead of the Fargate task's allocated memory, leading to aggressive heap expansion and subsequent OS-level kills.
We found that setting MaxRAMPercentage=75.0 provides enough headroom for the metaspace, code cache, and thread stacks while maximizing the heap. If your service uses off-heap memory (like Netty for reactive programming), this must be lowered to 50% or 60%.
The specific win we realized post-migration was in the cleanup of "Ghost Instances." In Beanstalk, an instance that became unresponsive would often stay in the load balancer for minutes before being terminated. With ECS Fargate’s aggressive health checks—configured with a HealthCheckIntervalSeconds of 15 and a HealthyThresholdCount of 2—we now eject failing tasks in under 45 seconds, significantly improving the availability of our API during intermittent database brownouts.
By focusing on layered builds, explicit ENI networking, and CodeDeploy-driven traffic shifting, the transition from legacy VMs to Fargate becomes a measurable upgrade in both developer velocity and system resilience. The sharp takeaway: if you aren't optimizing your Docker layers, you're paying for the technical debt of slow deployments, regardless of which container orchestrator you choose.
Cost economics of Fargate vs EC2 for a Spring Boot service
Fargate charges roughly 20–30% more per vCPU-hour than an equivalent EC2 instance, and you pay per running task rather than per instance. For a service running 24/7 at steady load, EC2 with a properly sized ASG is cheaper — the crossover is usually around 40–60% average utilisation. Fargate wins clearly in three cases: (1) traffic is spiky and you cannot keep an EC2 fleet warm — you pay Fargate for exactly the minutes you use. (2) You have many small services and would otherwise stack multiple on one instance; the bin-packing math rarely beats Fargate once you include unused overhead. (3) You do not have anyone who wants to own AMI patching, ECS agent upgrades and node draining — the flat premium buys you out of that job entirely. Below \$300/month total spend, Fargate is almost always the right default.
Go deeper
Further reading
Source Code
Get the full project on GitHub
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Cloud (AWS / Azure) →
Deploying Spring Boot to AWS: ECS Fargate End-to-End
Containerize a Spring Boot app, push to ECR, run on ECS Fargate behind an Application Load Balancer — production-ready in one tutorial.
Related tutorials
Deploying Spring Boot to AWS: ECS Fargate End-to-End
Containerize a Spring Boot app, push to ECR, run on ECS Fargate behind an Application Load Balancer — production-ready in one tutorial.
Serverless Java: Spring Boot on AWS Lambda with GraalVM
Deploy Spring Boot on AWS Lambda with GraalVM native image to eliminate cold starts — including build configuration, API Gateway integration, benchmarks and cost comparisons vs containers.
Spring Boot Microservices Architecture Explained Step by Step
A complete, beginner-friendly walkthrough of microservices architecture using Spring Boot — services, gateway, discovery, config and observability.
