Deploying a Spring Boot Microservice on AWS Fargate: Lessons From the Outage That Forced Me to Get It Right
Deploy a production-ready Spring Boot microservice on AWS Fargate with Docker, ECS, ALB health checks, private subnets, secrets, CI/CD, and autoscaling.
Join the DZone community and get the full member experience.
Join For FreeMy first attempt to deploy a Spring Boot microservice on AWS Fargate didn’t fail loudly. It failed quietly — in a loop. ECS kept launching tasks, the Application Load Balancer kept marking them unhealthy, and the service never stabilized. The logs looked fine, the container looked fine, but the ALB replaced every task within seconds.
The root cause was painfully simple: Spring Boot needed 45 seconds to start, and my ALB health‑check timeout was 5 seconds. The tasks never had a chance.
That night changed how I build and deploy microservices. It forced me to rethink startup behavior, JVM sizing, networking, task definitions, and the entire CI/CD pipeline. This article is the guide I wish I had before that incident — a practitioner’s walkthrough of deploying a production‑ready Spring Boot service on AWS Fargate, with real artifacts and the details that matter when things go wrong.
The Architecture That Finally Worked
Once the health‑check issue was fixed, the architecture settled into a predictable, cloud‑native flow:
- Developers push code to GitHub
- GitHub Actions builds the JAR
- Docker image is built and pushed to Amazon ECR
- ECS service runs AWS Fargate tasks
- Traffic enters through an Application Load Balancer
- Tasks run in private subnets
- Configuration comes from Parameter Store and Secrets Manager
- Logs and metrics flow to CloudWatch
It’s the standard modern microservice pipeline — but the difference between “standard” and “production‑ready” is in the details.
The Spring Boot Service
The microservice itself was simple — a REST API with a few endpoints. The real complexity wasn’t the controller logic; it was everything around it: startup time, health checks, configuration management, and container behavior under load.
A Dockerfile Built for Production
My first Dockerfile looked like the one many tutorials start with: a single‑stage build running as root with no JVM tuning. It worked locally but failed under real load. Fargate tasks with default JVM heap sizing inside a 2GB container are a classic OOM story.
Here’s the hardened version that finally stabilized deployments:
FROM eclipse-temurin:21-jre
# Create non-root user
RUN useradd -u 1001 springuser
WORKDIR /app
# Layer extraction for faster builds
COPY target/*.jar app.jar
# JVM tuning for Fargate
ENV JAVA_OPTS="\
-XX:MaxRAMPercentage=75 \
-XX:+UseContainerSupport \
-XX:+ExitOnOutOfMemoryError \
"
USER springuser
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
This eliminated the OOMKilled events I saw on 2GB tasks and made startup time predictable.
Pushing to Amazon ECR With Real Commands
The first time I wrote down my ECR commands, they were placeholders. In production, they need to be exact:
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com
docker build -t employee-service:1.0.3 .
docker tag employee-service:1.0.3 \
<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/employee-service:1.0.3
docker push \
<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/employee-service:1.0.3
Immutable semantic version tags make rollbacks predictable and prevent “latest‑tag roulette.”
The ECS Task Definition That Actually Runs in Production
A real Fargate deployment lives or dies by its task definition. Here’s the JSON I use today — including secrets pulled from Parameter Store and Secrets Manager:
{
"family": "employee-service",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/employeeServiceRole",
"containerDefinitions": [
{
"name": "employee-service",
"image": "<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/employee-service:1.0.3",
"portMappings": [
{ "containerPort": 8080, "protocol": "tcp" }
],
"secrets": [
{
"name": "DB_PASSWORD",
"valueFrom": "arn:aws:ssm:us-east-1:<ACCOUNT_ID>:parameter/db/password"
},
{
"name": "API_KEY",
"valueFrom": "arn:aws:secretsmanager:us-east-1:<ACCOUNT_ID>:secret:thirdparty/api"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/employee-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
The ALB Health Check That Stopped the Outage
My outage happened because the ALB was impatient. Here’s the configuration that finally stabilized deployments:
| setting | value |
|---|---|
|
Path |
/actuator/health |
|
Interval |
20 seconds |
|
Timeout |
10 seconds |
|
Healthy threshold |
3 |
|
Unhealthy threshold |
3 |
Spring Boot startup time + ALB patience = stable deployments.
Why Fargate Tasks Belong in Private Subnets
Early on, I deployed tasks in public subnets because it felt simpler. It wasn’t. Public IPs meant the containers were directly reachable from the internet — port scans, bot traffic, and noisy logs.
Moving tasks to private subnets solved several problems at once:
Reduced Attack Surface
No public IPs. No direct inbound traffic. Only the ALB can reach the tasks.
A Single Secure Entry Point
The ALB handles TLS termination, redirects HTTP→HTTPS, performs health checks, and integrates with WAF. Clients never bypass it.
Cleaner Security Groups
- ALB SG: inbound 443 from the internet
- Task SG: inbound only from ALB SG
Nothing else touches the containers.
Compliance Alignment
PCI, SOC 2, HIPAA — all prefer minimizing public exposure.
Controlled Outbound Access
Tasks use a NAT Gateway for outbound calls (updates, third‑party APIs) without exposing themselves.
Better Scalability
ALB target groups automatically track tasks across AZs as ECS scales.
The architecture becomes simple and predictable:
Internet → ALB (public subnets) → Fargate tasks (private subnets)
It’s quieter, safer, and easier to operate.
The GitHub Actions Workflow That Deploys Automatically
Here’s the pipeline that builds, tests, pushes, and deploys the service:
name: Deploy to Fargate
on:
push:
branches: ["main"]
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: "21"
- name: Build JAR
run: mvn -B clean package
- name: Login to ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Build and Push Image
run: |
docker build -t employee-service:1.0.3 .
docker tag employee-service:1.0.3 ${{ env.ECR_REGISTRY }}/employee-service:1.0.3
docker push ${{ env.ECR_REGISTRY }}/employee-service:1.0.3
- name: Deploy ECS Service
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ecs-task.json
service: employee-service
cluster: prod-cluster
Auto Scaling With Real Target Tracking JSON
Target tracking is the simplest and most reliable scaling strategy for Fargate:
{
"TargetValue": 50.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleOutCooldown": 30,
"ScaleInCooldown": 60
}
I use 50% as the target because it balances cost and responsiveness.
What I Learned
Every failure taught me something:
- ALB timeouts taught me to respect startup time
- OOMKilled tasks taught me to tune the JVM
- Public subnets taught me to isolate workloads
- Manual deployments taught me to automate everything
AWS Fargate really does deliver on its promise — no servers to manage, automatic scaling, and clean integration with ECS — but only after you learn the hard parts.
If you’re deploying Spring Boot on Fargate, I hope you learn those lessons from this article instead of from your own outage.
Opinions expressed by DZone contributors are their own.
Comments