A practical guide to deploying containerised Spring Boot applications on AWS ECS with Fargate — Dockerfile, ECR, task definitions, ALB, Parameter Store, and GitHub Actions CI/CD.
Running a Spring Boot service on ECS Fargate is straightforward once you’ve done it a few times, but the first time you encounter the combination of ECR, task definitions, ALB target groups, Parameter Store injection, and GitHub Actions all needing to agree with each other, it can feel like an awful lot of moving parts. This post walks through the whole chain — from Dockerfile to a live, auto-deploying service — with the decisions that actually matter explained.
The setup I describe here is close to what I’ve used for microservices at DWP Digital: containerised Spring Boot, Fargate compute (no EC2 instances to babysit), ALB for HTTPS termination, Parameter Store for secrets, and GitHub Actions for the deployment pipeline.
The most common mistake is treating a Java Dockerfile like a Node or Python one. Java has two specific requirements: a JDK at build time, a JRE at runtime, and the layer cache needs to be structured around Maven’s dependency resolution.
# Stage 1: dependency resolution (cached unless pom.xml changes)
FROM eclipse-temurin:21-jdk-alpine AS dependencies
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q
# Stage 2: build
FROM dependencies AS build
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Stage 3: runtime image (JRE only — ~100MB smaller than JDK)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-jar", "app.jar"]
-XX:+UseContainerSupport tells the JVM to respect cgroup memory limits rather than using the host’s total RAM. Without it, the JVM may size its heap based on the EC2 host and immediately trigger an OOM kill in the container. -XX:MaxRAMPercentage=75.0 allocates 75% of the container’s memory limit to the heap, leaving room for off-heap memory (Metaspace, thread stacks, direct buffers).
Create a private ECR repository and push from your local machine to verify the setup before wiring GitHub Actions:
aws ecr get-login-password --region eu-west-1 \
| docker login --username AWS \
--password-stdin 123456789.dkr.ecr.eu-west-1.amazonaws.com
docker build -t my-service .
docker tag my-service:latest \
123456789.dkr.ecr.eu-west-1.amazonaws.com/my-service:latest
docker push 123456789.dkr.ecr.eu-west-1.amazonaws.com/my-service:latest
Enable ECR image scanning on push — it’s one checkbox in the console and gives you a free CVE scan of every image before it runs in production.
The task definition is where CPU, memory, environment variables, and the container image come together. Define it as JSON and keep it in source control:
{
"family": "my-service",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789:role/my-service-task-role",
"containerDefinitions": [
{
"name": "my-service",
"image": "123456789.dkr.ecr.eu-west-1.amazonaws.com/my-service:latest",
"portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
"environment": [
{ "name": "SPRING_PROFILES_ACTIVE", "value": "prod" }
],
"secrets": [
{
"name": "SPRING_DATASOURCE_PASSWORD",
"valueFrom": "arn:aws:ssm:eu-west-1:123456789:parameter/my-service/prod/db-password"
}
],
"healthCheck": {
"command": ["CMD-SHELL",
"wget -qO- http://localhost:8080/actuator/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-service",
"awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
The startPeriod: 60 on the health check is important. Fargate marks a container unhealthy if it fails health checks before Spring Boot has finished starting. Without startPeriod, a slow JVM startup (common on first boot when the JIT is cold) can trigger a replacement cycle before the service is even ready.
Never bake secrets into environment variables in the task definition plaintext. Use SSM Parameter Store with SecureString parameters:
// In application.properties (prod profile):
// spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
// The ECS task injects SPRING_DATASOURCE_PASSWORD from Parameter Store at runtime
The ECS task execution role needs ssm:GetParameters and kms:Decrypt permissions for the parameter ARNs referenced in the secrets block. Scope the IAM policy tightly — only the parameters this specific service needs:
{
"Effect": "Allow",
"Action": ["ssm:GetParameters", "kms:Decrypt"],
"Resource": [
"arn:aws:ssm:eu-west-1:123456789:parameter/my-service/prod/*"
]
}
Wire ECR push and ECS deployment into GitHub Actions on merge to main:
name: Deploy to ECS
on:
push:
branches: [main]
env:
AWS_REGION: eu-west-1
ECR_REGISTRY: 123456789.dkr.ecr.eu-west-1.amazonaws.com
ECR_REPOSITORY: my-service
ECS_CLUSTER: my-cluster
ECS_SERVICE: my-service
CONTAINER_NAME: my-service
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Build JAR
run: mvn -B package -DskipTests
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Log in to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push Docker image
id: build-image
run: |
IMAGE_TAG=${{ github.sha }}
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Download task definition
run: |
aws ecs describe-task-definition \
--task-definition my-service \
--query taskDefinition > task-definition.json
- name: Update ECS task definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
wait-for-service-stability: true holds the GitHub Actions workflow open until ECS has replaced the old task with the new one and the health checks pass. If deployment fails, the action fails — you get a red build rather than a silent bad deployment.
ProTip: Java workloads are consistently undersized on Fargate. The temptation is to start with 256 CPU / 512MB memory and scale up. In practice, Spring Boot with a typical set of dependencies (security, data, actuator, web) needs at least 512 CPU / 1024MB to run comfortably. Below that threshold, startup times lengthen (the JIT is CPU-starved), GC pressure increases, and you see latency spikes under load.
For a trading service where response time consistency matters, I run 1024 CPU / 2048MB for the primary containers. It costs more than you might expect (Fargate pricing adds up quickly for always-on services), but the performance consistency is worth it. If cost is a concern, consider moving steady-state workloads to EC2 Spot with ECS capacity providers and reserving Fargate for burst — but that’s a topic for another post.
Cold start impact is real but manageable. The first request after a deployment hits a cold JVM. Configure Spring Boot’s lazy initialisation (spring.main.lazy-initialization=true) and set the ECS health check startPeriod generously — the ALB won’t route traffic until the health check passes, so the cold start happens before any real traffic hits.
If you’re deploying Java services to AWS and want an engineer who has been through this in production, get in touch.