Available Hire Me
← All Writing AWS

Building a CI/CD Pipeline for Spring Boot on ECS — from Git Push to Safe Deploy

A production CI/CD pipeline for Spring Boot on ECS — GitHub Actions, parallel tests, Trivy scan, ECR push, CDK deploy with approval gates and rollback.

A Spring Boot service running on ECS Fargate is only as reliable as the path that gets new versions to it. Deploying by hand — or by a pipeline that skips tests, scans or verification — turns every release into a gamble. A well-built CI/CD pipeline makes the risky parts routine: the same code path runs parallel tests, builds the image, scans it, pushes to ECR, deploys to ECS, and verifies the new version before it takes traffic. This post is the pipeline I build for Spring Boot services on ECS, end to end.

The pipeline shape

A production pipeline has five stages, and each one fails loudly:

test → build & scan → push to ECR → deploy → verify

Every stage runs in its own GitHub Actions job, so a failure is attributable to exactly one stage and its logs. The whole pipeline runs on every push to main and on every pull request — the only difference is that PRs stop before the deploy.

Tests as the first gate

The test job runs Maven with the full test suite, including the Testcontainers integration tests — the same tests that run locally run in CI, against a real PostgreSQL in a container. Fail here and nothing else starts:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - run: mvn --batch-mode verify

The integration tests need Docker, which GitHub Actions runners provide out of the box. If you’re not yet running Testcontainers in CI, the integration testing with Testcontainers post shows the setup that makes this stage meaningful rather than decorative.

Build and scan

The build job produces the Docker image and scans it before it is allowed anywhere near a registry. A multi-stage build keeps the runtime image small — Maven build in one stage, JRE runtime in the next:

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY . .
RUN mvn --batch-mode -DskipTests package

FROM eclipse-temurin:21-jre
COPY --from=build /src/target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Scanning with Trivy runs in the pipeline and fails on critical or high vulnerabilities:

      - name: Scan image
        run: trivy image --severity CRITICAL,HIGH --exit-code 1 app:latest

A failing scan blocks the release — that is the point of the gate. It also prevents the “we’ll fix it next sprint” drift that leaves known-critical CVEs in production containers for months.

Push to ECR

The image is pushed to Amazon ECR with a digest-pinned reference, so the deploy stage deploys exactly the bytes that were scanned — not whatever latest happens to point at by then:

      - name: Push to ECR
        run: |
          IMAGE_URI=$/app:$
          docker tag app:latest "$IMAGE_URI"
          docker push "$IMAGE_URI"
          echo "image=$IMAGE_URI" >> "$GITHUB_OUTPUT"

The commit SHA as the tag gives you an immutable, traceable artifact: the image in production tells you the exact commit it came from.

Deploy with CDK

The deploy job runs the AWS CDK to update the ECS service, referencing the freshly pushed image URI. If you use CodeDeploy blue/green on ECS — with traffic shifting, validation hooks and CloudWatch-alarm rollback — the deployment configuration lives in CDK and the pipeline merely triggers it; I covered that configuration in detail in the blue/green deployments on ECS Fargate post, and the CDK basics in CDK for Java as infrastructure-as-code.

An approval gate before the deploy step is the difference between “the pipeline deploys” and “a human confirmed we’re releasing”. On main it is optional; for environments like production it should be mandatory:

      - name: Wait for approval
        uses: trstringer/manual-approval@v1
        with:
          secret: $

Verify after deploy

Deploying is not finishing. The pipeline’s last job hits the service’s health endpoint through the load balancer, checks the new version string, and runs a smoke test against a real endpoint:

      - name: Smoke test
        run: |
          curl --fail --retry 30 --retry-delay 5 \
            "https://app.example.com/actuator/health"
          curl --fail "https://app.example.com/api/version" | grep "$"

If the new version never becomes healthy, the blue/green deployment’s CloudWatch alarms roll back automatically — the pipeline reports the failure, and production is still on the previous, known-good version.

What the pipeline is really for

The pipeline’s job is to make deploys boring. Parallel tests that run every time, a scan that blocks known-vulnerable images, an immutable image tag, an approval gate, and a verification step that proves the new version is actually serving — each of these removes one class of “it worked on my machine” failure. When the pipeline is this shape, a release is a routine event, and an incident is almost never “the deploy”.

The /cicd/ pipeline visualiser on this site animates this exact flow — git push through parallel tests, security scanning, Docker build, ECR push, and a blue/green ECS deployment with realistic timings and occasional failures with auto-retry. It is the pipeline from this post, drawn.

If you’re moving a Spring Boot service onto ECS and want the pipeline built properly the first time, let’s build it together.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.