Deploy to ECS Fargate with CI/CD

Deploy a Spring Boot app to ECS Fargate with a GitHub Actions CI/CD pipeline.

Introduction

30 Second Summary

Every team that ships software faces the same moment of tension: you've built something that works locally, but getting it live means manual steps, exposed credentials, and no safety net if something breaks. One wrong deployment can take down a service with no quick way to recover.

In this project, you will deploy your existing threat-event Spring Boot application to AWS ECS Fargate behind a load balancer, with a fully automated CI/CD pipeline that builds, tests, and deploys on every push to main. If a bad deployment slips through, the system automatically rolls back without any manual intervention.

What You'll Build

You push a commit, walk away, and come back to find your threat-event API live on a public URL. If you push a broken version, you watch the system detect the failure and revert itself while you do nothing.

By the end of this project, you'll have:

  • A live public URL serving your threat-event API's health endpoint and OpenAPI documentation, running on ECS Fargate behind an Application Load Balancer.
  • A GitHub Actions pipeline that authenticates to AWS using OIDC (zero stored credentials), runs your tests, builds a Docker image tagged with the exact commit SHA, and deploys it automatically.
  • A proven automatic rollback safety net where you intentionally deploy a broken image, watch the deployment circuit breaker trigger, and confirm the previous healthy version is restored without any manual action.
  • Secret Mission: Add a deploy-status badge and architecture diagram to a portfolio-ready README, plus implement a manual rollback workflow for emergencies beyond automated detection.

Are there any prerequisites?

This project builds directly on Projects 1 and 2 in this series. You need a working Dockerized Spring Boot threat-event platform, an active AWS account, Docker Desktop, AWS CLI v2, a GitHub account with Actions enabled, and Java 17+ with Maven installed.

Before We Start

Before diving into infrastructure commands and pipeline configuration, this is your moment to commit to what you're building and why it matters.

You're about to take your threat-event platform from a local Docker Compose setup to a live AWS deployment with a fully automated, secure pipeline.

Prepare Production Dockerfile and ECR Repository

Your Docker Compose setup works locally, but production needs an optimized image and a private registry to store it. Without Amazon ECR, GitHub Actions has nowhere to push your built images.

In this step, you'll create a production-ready multi-stage Dockerfile, add a standalone Spring profile for deployment, and set up an ECR repository to hold your images.

In this step, get ready to:
  • Create a production-optimized multi-stage Dockerfile.
  • Add an application-deploy.yml Spring profile that starts the app without backing services.
  • Create an ECR repository and push a test image to verify the registry works.
Create the production Dockerfile

A multi-stage Docker build separates the build environment (Maven, full JDK) from the runtime environment (slim JRE only). This produces a smaller, more secure production image.

  • Create a new file called Dockerfile in your project root (the same directory as pom.xml), and add the following multi-stage build configuration:
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENV SPRING_PROFILES_ACTIVE=deploy
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

The first stage (build) uses Maven to compile your Spring Boot app and produce the JAR. Downloading dependencies first (dependency:go-offline) lets Docker cache them, speeding up future builds.

The second stage copies only the built JAR into a slim Alpine JRE image. The HEALTHCHECK instruction tells Docker (and later ECS) how to verify the container is healthy by hitting the Actuator health endpoint.

  • Save Dockerfile.
  • Confirm Dockerfile appears in your project root alongside pom.xml in the file explorer sidebar.
Add the deploy Spring profile

Your app depends on Kafka, PostgreSQL, and Elasticsearch. In production you would connect to managed AWS services for each of these. For this deployment, you need a standalone profile that starts the app without any of those connections so it can pass health checks and serve API documentation.

  • Create the file src/main/resources/application-deploy.yml and add the following header and Kafka disabling configuration:
# =============================================================================
# PORTFOLIO DEPLOYMENT PROFILE (application-deploy.yml)
# =============================================================================
# This profile is a TEMPORARY standalone configuration for demonstrating the
# CI/CD pipeline and ECS deployment without provisioning managed backing services.
#
# It is NOT a production architecture. A real production deployment would:
# - Use application-prod.yml with live Amazon RDS, MSK, and OpenSearch endpoints
# - Enable all health indicators (db, elasticsearch, kafka) so /actuator/health
#   reflects true dependency status and ALB can drain unhealthy tasks
# - Run multiple tasks across AZs with auto-scaling
# - Use private subnets with NAT gateways and VPC endpoints
#
# What this profile does:
# - Starts the JVM and Spring context without requiring any external service
# - Reports /actuator/health as UP (application health only, not dependency health)
# - Serves OpenAPI/Swagger documentation for API reference
# - Returns 503 on endpoints that require backing services (event ingestion, search)
# =============================================================================

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
  kafka:
    listener:
      auto-startup: false

What does this section do?

The spring.autoconfigure.exclude property completely removes Kafka auto-configuration so no broker connection is attempted at startup. Setting spring.kafka.listener.auto-startup to false ensures no Kafka listeners try to start even if other configuration pulls them in.

  • Below the kafka configuration in the same file, add the datasource and persistence settings:
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:threat_events}
    username: ${DB_USERNAME:threat_app}
    password: ${DB_PASSWORD:}
    hikari:
      initialization-fail-timeout: -1
      maximum-pool-size: 5
  elasticsearch:
    uris: ${ELASTICSEARCH_HOST:}
  jpa:
    hibernate:
      ddl-auto: none
    open-in-view: false
    properties:
      hibernate:
        boot:
          allow_jdbc_metadata_access: false

What does this section do?

Setting initialization-fail-timeout: -1 tells HikariCP to initialize the connection pool lazily in the background. The app starts even without a reachable database.

The empty elasticsearch.uris value means no Elasticsearch connection is attempted. The JPA settings prevent Hibernate from trying to access database metadata at startup.

  • Below the JPA settings, add the Actuator, server, and logging configuration:
management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
  health:
    db:
      enabled: false
    elasticsearch:
      enabled: false

server:
  port: 8080

logging:
  pattern:
    console: '{"timestamp":"%d{ISO8601}","level":"%p","logger":"%logger","message":"%m"}%n'

What does this section do?

The management section exposes only the health and info Actuator endpoints. Disabling the database and Elasticsearch health indicators means /actuator/health returns UP based on application health alone.

The logging pattern produces structured JSON so CloudWatch can parse and filter your container logs.

  • Save application-deploy.yml.
  • Confirm the file appears under src/main/resources/ in your file explorer sidebar.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Your Dockerfile should contain exactly the 14-line multi-stage build shown above. Your application-deploy.yml should contain these sections in order:

  • Comment header (18 lines explaining the profile's purpose).
  • spring.autoconfigure.exclude and spring.kafka.listener section.
  • spring.datasource with HikariCP settings, spring.elasticsearch, and spring.jpa section.
  • management section with health and info endpoints exposed.
  • server section setting port 8080.
  • logging section with JSON pattern for CloudWatch.

Scroll up and confirm each section is present in that order.

File not appearing in the right location?

Make sure application-deploy.yml lives at src/main/resources/application-deploy.yml (not in the project root). Spring Boot only picks up profiles from the resources directory.

help me find where Spring profile files should go in my project

Create the ECR repository and push a test image

Amazon ECR is a private container registry that stores your Docker images. GitHub Actions will push images here during CI/CD, and ECS will pull from here at deploy time.

  • Create the ECR repository with image scanning enabled by running:
aws ecr create-repository --repository-name threat-event-platform --image-scanning-configuration scanOnPush=true

You should see JSON output containing a repositoryUri field. Copy this value. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/threat-event-platform.

Why enable scanOnPush?

Image scanning automatically checks every pushed image for known vulnerabilities. This gives you a security baseline without any extra configuration.

Seeing a RepositoryAlreadyExistsException?

If you already created this repository in a previous attempt, that error is safe to ignore. Run the describe command in the next substep to confirm it exists.

help me resolve my ECR repository creation error

  • Authenticate Docker to your ECR registry by running:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com

You should see Login Succeeded in the output.

Find your AWS account ID

Run aws sts get-caller-identity --query Account --output text to print your 12-digit account ID.

  • Build the Docker image locally and tag it with a test SHA by running:
docker build -t threat-event-platform:abc123def .

The build takes a few minutes. Maven downloads dependencies and compiles your Spring Boot app inside the container. You should see a final line confirming the image was tagged successfully.

  • Tag the image for ECR and push it by running:
docker tag threat-event-platform:abc123def [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/threat-event-platform:abc123def
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/threat-event-platform:abc123def

You should see layer upload progress followed by a digest confirmation when the push completes.

Push failing with authorization error?

ECR login tokens expire after 12 hours. If your push fails with an authorization error, re-run the aws ecr get-login-password command above and try again.

help me debug my ECR push authorization failure

Verify the image appears in ECR

Before you run this, what do you expect to see in the image list?

  • List the images in your ECR repository by running:
aws ecr list-images --repository-name threat-event-platform

You should see JSON output with an imageIds array containing your abc123def tag.

  • Confirm the repository details with the describe command:
aws ecr describe-repositories --repository-names threat-event-platform

The output includes a repositoryUri field confirming your registry is ready for CI/CD.

No images showing up?

Double-check that the tag in your docker push command matches the full ECR URI including your account ID and region. A mismatched URI pushes to nowhere.

help me figure out why my image isn't appearing in ECR

Your image is in ECR and ready to be pulled by ECS. Next up, you'll deploy it to Fargate behind a load balancer and see your app running live on AWS.

Deploy to ECS Fargate with ALB and Health Checks

Your Docker image is sitting in ECR, but nobody can reach it. Without compute infrastructure and a public endpoint, your container is just stored bytes in a private registry.

In this step, you'll build the infrastructure that runs your container and makes it accessible over the internet. You'll create an ECS cluster, define exactly how your task should run, set up a load balancer with health checks, and launch the service.

In this step, get ready to:
  • Create the ECS cluster, task execution role, and task definition.
  • Set up an Application Load Balancer with health checks.
  • Launch the ECS service and verify it responds publicly.
Create the ECS cluster and task execution role

Before you can run containers, ECS needs a cluster (a logical grouping for your services) and an IAM role that grants your task permission to pull images from ECR and write logs to CloudWatch.

  • Create the ECS cluster by running this command:
aws ecs create-cluster --cluster-name threat-event-cluster

You should see JSON output with "status": "ACTIVE" confirming the cluster is ready.

  • Create the task execution IAM role by running:
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"
    }]
  }'
  • Attach the managed ECS execution policy to the role by running:
aws iam attach-role-policy \
  --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

What does ecsTaskExecutionRole do?

This role is assumed by the ECS agent (not your application code). It grants permission to pull your container image from ECR, push logs to CloudWatch, and later read secrets from Secrets Manager.

  • Create the CloudWatch log group that your containers will write to:
aws logs create-log-group --log-group-name /ecs/threat-event-platform

No output means success. The log group now exists.

Create the task definition

The task definition is a JSON blueprint telling ECS exactly how to run your container. It specifies CPU, memory, environment variables, health checks, and log configuration.

  • Create the .aws directory and the task definition file by running:
mkdir -p .aws
  • Open .aws/task-definition.json in your editor and paste the task family, container basics, and health check:
{
  "family": "threat-event-platform",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID="your AWS account ID"]]:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "threat-event-platform",
      "image": "[[AWS_ACCOUNT_ID="your AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/threat-event-platform:latest",
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1"],
        "interval": 15,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 30
      },

What does this configure?

  • 0.5 vCPU and 1 GB memory is the smallest Fargate size that comfortably runs Spring Boot.
  • awsvpc network mode gives each task its own network interface (required for Fargate).
  • The health check hits /actuator/health every 15 seconds with a 30-second grace period for JVM startup.
  • Continue in the same file directly after the health check section. Add the environment variables and log configuration to complete the file:
      "environment": [
        {"name": "SPRING_PROFILES_ACTIVE", "value": "deploy"},
        {"name": "DB_HOST", "value": "localhost"},
        {"name": "DB_PORT", "value": "5432"},
        {"name": "DB_NAME", "value": "threat_events"},
        {"name": "DB_USERNAME", "value": "threat_app"},
        {"name": "KAFKA_BOOTSTRAP_SERVERS", "value": ""},
        {"name": "ELASTICSEARCH_HOST", "value": ""}
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/threat-event-platform",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "essential": true
    }
  ]
}

What do these environment variables do?

Setting SPRING_PROFILES_ACTIVE=deploy activates your standalone profile. Empty values for KAFKA_BOOTSTRAP_SERVERS and ELASTICSEARCH_HOST prevent connection attempts to services you haven't provisioned.

The awslogs driver streams container stdout to CloudWatch so you can inspect startup logs without SSH access.

  • Save .aws/task-definition.json.
  • Register the task definition in ECS by running:
aws ecs register-task-definition --cli-input-json file://.aws/task-definition.json

You should see JSON output including "revision": 1 confirming the definition is registered.

Seeing a JSON parsing error?

Check that your .aws/task-definition.json is valid JSON. A missing comma between sections or a mismatched brace is the most common cause. Paste the file contents into a JSON validator to locate the issue.

help me fix a JSON parsing error when registering my ECS task definition

✔️ Awesome, I've got everything!

Great. Double check you've saved .aws/task-definition.json and your registration returned revision 1.

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains both chunks in order:

  • Chunk 1: Task family through the healthCheck closing brace and comma.
  • Chunk 2: environment array, logConfiguration, essential flag, and all closing braces.

The complete file should start with { on line 1 and end with a single } on the last line, with no trailing commas after the last element in any array or object.

Create the ALB, target group, and listener

An Application Load Balancer gives your service a stable public URL and routes traffic to healthy containers. You need a security group, the ALB itself, a target group that knows how to health-check your containers, and a listener that ties them together.

  • First, find your default VPC ID and public subnet IDs by running:
aws ec2 describe-vpcs --filters Name=isDefault,Values=true \
  --query 'Vpcs[0].VpcId' --output text
  • Copy the VPC ID from the output. Then find your public subnets by running:
aws ec2 describe-subnets \
  --filters Name=vpc-id,Values=[[VPC_ID="your VPC ID from above"]] \
  --query 'Subnets[?MapPublicIpOnLaunch==`true`].[SubnetId]' --output text

Need at least two subnets

An ALB requires subnets in at least two Availability Zones. Copy two subnet IDs from the output. If your default VPC only shows one public subnet, use any two subnets from different AZs.

  • Create a security group for the ALB that allows inbound HTTP from anywhere:
aws ec2 create-security-group \
  --group-name threat-event-alb-sg \
  --description "ALB security group" \
  --vpc-id [[VPC_ID="your VPC ID"]]
  • Copy the GroupId from the output. Then allow inbound HTTP traffic by running:
aws ec2 authorize-security-group-ingress \
  --group-id [[ALB_SG_ID="your ALB security group ID"]] \
  --protocol tcp --port 80 --cidr 0.0.0.0/0
  • Create the Application Load Balancer by running:
aws elbv2 create-load-balancer \
  --name threat-event-alb \
  --subnets [[SUBNET_1="first subnet ID"]] [[SUBNET_2="second subnet ID"]] \
  --security-groups [[ALB_SG_ID="your ALB security group ID"]] \
  --scheme internet-facing \
  --type application

The output includes a DNSName field. Copy this URL. You'll use it at the end to verify everything works.

  • Also copy the LoadBalancerArn from the output. Create the target group with the Actuator health check by running:
aws elbv2 create-target-group \
  --name threat-event-tg \
  --protocol HTTP \
  --port 8080 \
  --target-type ip \
  --vpc-id [[VPC_ID="your VPC ID"]] \
  --health-check-path /actuator/health \
  --health-check-interval-seconds 15 \
  --healthy-threshold-count 2

Why IP target type?

Fargate tasks use awsvpc networking, which assigns each task its own elastic network interface with a private IP. The ALB routes directly to these IPs rather than to EC2 instance IDs.

  • Copy the TargetGroupArn from the output. Create the listener that forwards port 80 traffic to your target group:
aws elbv2 create-listener \
  --load-balancer-arn [[ALB_ARN="your ALB ARN"]] \
  --protocol HTTP --port 80 \
  --default-actions Type=forward,TargetGroupArn=[[TG_ARN="your target group ARN"]]

You should see a Listeners array in the output confirming port 80 is now forwarding to your target group.

Seeing an access denied error?

Confirm your AWS CLI credentials have the elasticloadbalancing:* and ec2:* permissions needed to create load balancers and security groups. If using an IAM user, check the attached policies.

help me fix ALB access denied errors when creating the load balancer

Launch the ECS service and verify

The final piece is the ECS service. This tells Fargate to run your task, register it with the ALB, and automatically roll back if a deployment fails health checks.

  • Create a security group for the ECS tasks that allows traffic from the ALB:
aws ec2 create-security-group \
  --group-name threat-event-ecs-sg \
  --description "ECS task security group" \
  --vpc-id [[VPC_ID="your VPC ID"]]
  • Copy the new GroupId. Allow inbound traffic on port 8080 from the ALB security group:
aws ec2 authorize-security-group-ingress \
  --group-id [[ECS_SG_ID="your ECS task security group ID"]] \
  --protocol tcp --port 8080 \
  --source-group [[ALB_SG_ID="your ALB security group ID"]]
  • Create the ECS service with the deployment circuit breaker enabled by running:
aws ecs create-service \
  --cluster threat-event-cluster \
  --service-name threat-event-service \
  --task-definition threat-event-platform \
  --desired-count 1 \
  --launch-type FARGATE \
  --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
  --network-configuration "awsvpcConfiguration={subnets=[[[SUBNET_1="first subnet ID"]],[[SUBNET_2="second subnet ID"]]],securityGroups=[[[ECS_SG_ID="your ECS task security group ID"]]],assignPublicIp=ENABLED}" \
  --load-balancers "targetGroupArn=[[TG_ARN="your target group ARN"]],containerName=threat-event-platform,containerPort=8080"

What is the deployment circuit breaker?

When deploymentCircuitBreaker={enable=true,rollback=true} is set, ECS monitors new deployments. If tasks repeatedly fail to start or pass health checks, ECS automatically rolls back to the last working version instead of endlessly retrying.

  • Wait for the service to stabilize by running:
aws ecs wait services-stable \
  --cluster threat-event-cluster \
  --services threat-event-service

This command blocks until your task is running and passing health checks. It may take 1-3 minutes for the Spring Boot JVM to start and the Actuator endpoint to respond.

Service not stabilizing?

Check your task's status and stop reason:

aws ecs describe-services \
  --cluster threat-event-cluster \
  --services threat-event-service \
  --query 'services[0].events[0:3]'

Common causes: the security group does not allow traffic on port 8080 from the ALB, or the image URI in the task definition has a typo. Verify the image exists in ECR and the ECS task security group allows inbound 8080 from the ALB security group.

help me debug why my ECS Fargate service is not reaching a steady state

Before you open the URL, predict: what JSON response do you expect from the ALB DNS address?

  • Open your ALB DNS URL in a browser or run:
curl http://[[ALB_DNS="your ALB DNS name from the create-load-balancer output"]]/actuator/health

You should see {"status":"UP"} from the Actuator health endpoint. Your threat-event platform is running on AWS and publicly accessible.

Your container is running live on AWS behind a load balancer. Next up, you'll integrate Secrets Manager for secure credential injection and configure production environment variables.

Configure Secrets Manager and Production Environment Variables

Your threat-event platform is running on ECS, but it is using placeholder values for database credentials. In a production system, sensitive values like passwords must never appear as plaintext environment variables visible in the AWS console.

AWS Secrets Manager solves this by injecting credentials directly into the container at startup. ECS reads the secret and passes it as an environment variable inside the container without exposing it in task definitions or console logs.

In this step, get ready to:
  • Create a secret in AWS Secrets Manager and grant the task execution role permission to read it.
  • Update the task definition to inject the secret at container startup.
  • Deploy the updated task definition and verify successful startup in CloudWatch.
Create the Secrets Manager secret

Your application-deploy.yml references ${DB_PASSWORD:} as a Spring placeholder. Right now, ECS passes nothing for that value. You need a secure place to store the password so ECS can inject it at runtime.

  • Create the secret by running this command:
aws secretsmanager create-secret --name threat-event-platform/db-password --secret-string "your-db-password-here"

What does this command do?

This stores a secret string under the name threat-event-platform/db-password in AWS Secrets Manager. The value your-db-password-here is a placeholder for this learning deployment.

In a real production environment, you would use a strong generated password that matches your RDS instance.

  • Confirm the secret was created by running:
aws secretsmanager describe-secret --secret-id threat-event-platform/db-password --query '{Name:Name,ARN:ARN}' --output table

You should see a table showing the secret name and its full ARN. Copy the ARN value. You will need it in the next substep.

Seeing an error about the secret already existing?

If you previously created a secret with the same name, Secrets Manager may still have it in a pending-deletion state. Run the following command to restore it, then retry the describe command above.

aws secretsmanager restore-secret --secret-id threat-event-platform/db-password

Help me fix my Secrets Manager secret creation error

Grant the task execution role permission to read the secret

ECS uses the task execution role to pull secrets on your container's behalf. Without the right permission, the task will fail to start because it cannot read from Secrets Manager.

  • Add an inline policy to the execution role by running this command:
aws iam put-role-policy \
  --role-name ecsTaskExecutionRole \
  --policy-name SecretsManagerReadPolicy \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Action": "secretsmanager:GetSecretValue",
        "Resource": "arn:aws:secretsmanager:us-east-1:[[AWS_ACCOUNT_ID="your-account-id-here"]]:secret:threat-event-platform/db-password*"
      }
    ]
  }'

What does this policy do?

This grants the ecsTaskExecutionRole permission to call secretsmanager:GetSecretValue on your specific secret. The wildcard at the end of the resource ARN accounts for the random suffix that Secrets Manager appends to the secret ARN internally.

Scoping the policy to a single secret follows the principle of least privilege. The role can only read this one secret, not every secret in your account.

  • Verify the policy was attached by running:
aws iam get-role-policy --role-name ecsTaskExecutionRole --policy-name SecretsManagerReadPolicy --query 'PolicyDocument' --output json

You should see the JSON policy document you just created, confirming the permission is in place.

Getting a NoSuchEntity error?

Make sure the role name is exactly ecsTaskExecutionRole with that exact casing. If you named it differently in Step 2, use that name instead.

Help me fix this NoSuchEntity error

Add the secrets reference to the task definition and deploy

Your task definition already has regular environment variables for non-sensitive config. The secrets block works differently. It tells ECS to fetch the value from Secrets Manager at task launch and inject it as an environment variable inside the container.

  • Open .aws/task-definition.json in your editor.
  • Find the closing bracket of the environment array. Add the following secrets block directly below it, before the logConfiguration section:
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:your-account-id-here:secret:threat-event-platform/db-password"
        }
      ],

How is secrets different from environment?

The environment block stores values in plaintext. Anyone with console access can read them. The secrets block stores only a reference to the Secrets Manager ARN. ECS resolves the actual value at runtime and injects it as DB_PASSWORD inside the container.

Your application-deploy.yml already references ${DB_PASSWORD:} in the datasource config, so Spring Boot automatically picks up the injected value.

What about the existing environment variables?

The environment block contains non-sensitive configuration. SPRING_PROFILES_ACTIVE=deploy activates the standalone profile. Empty strings for KAFKA_BOOTSTRAP_SERVERS and ELASTICSEARCH_HOST disable those connections entirely.

In a full production deployment, you would replace DB_HOST with an Amazon RDS endpoint, KAFKA_BOOTSTRAP_SERVERS with Amazon MSK bootstrap servers, ELASTICSEARCH_HOST with an Amazon OpenSearch domain endpoint, and switch SPRING_PROFILES_ACTIVE from deploy to prod.

  • Replace your-account-id-here in the valueFrom field with your actual AWS account ID.
  • Save .aws/task-definition.json.

✔️ Awesome, I've got everything!

Great. Make sure you saved .aws/task-definition.json before continuing.

ⓧ I'd like to double check the full code

Your .aws/task-definition.json should contain these sections in order. Scroll up and confirm each is present:

  • The family, networkMode, requiresCompatibilities, cpu, memory, and executionRoleArn fields at the top level.
  • A containerDefinitions array with one container named threat-event-platform.
  • The container's portMappings on port 8080 and healthCheck using wget against /actuator/health.
  • The environment array with 7 entries: SPRING_PROFILES_ACTIVE, DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, KAFKA_BOOTSTRAP_SERVERS, ELASTICSEARCH_HOST.
  • The new secrets array with one entry for DB_PASSWORD pointing to your Secrets Manager ARN.
  • The logConfiguration block with awslogs driver and essential set to true at the end.
  • Register the updated task definition by running:
aws ecs register-task-definition --cli-input-json file://.aws/task-definition.json

You should see output confirming the task definition was registered as revision 2.

  • Update the ECS service to use the new revision by running:
aws ecs update-service \
  --cluster threat-event-cluster \
  --service threat-event-service \
  --task-definition threat-event-platform:2 \
  --force-new-deployment
  • Wait for the service to stabilize by running:
aws ecs wait services-stable --cluster threat-event-cluster --services threat-event-service

This command blocks until the new task is healthy and the old task has drained. It may take 2-3 minutes.

Service not stabilizing?

If the wait times out, the task may be failing to start. Check the stopped task's reason with aws ecs describe-tasks on the task ARN. The most common cause is the IAM policy not matching the secret ARN exactly.

Verify your account ID is correct in both the policy resource and the task definition's valueFrom field.

Help me diagnose my ECS service instability

Before you check the logs, what do you expect to see in CloudWatch given that the deploy profile disables Kafka and defers the database connection?

  • Verify the application started correctly by checking CloudWatch Logs:
aws logs tail /ecs/threat-event-platform --since 5m --format short

You should see structured JSON startup logs with the deploy profile active. Look for messages indicating the Kafka listener is disabled and the datasource connection is deferred. No connection errors should be halting startup.

What do these logs confirm?

The structured JSON output proves three things. Spring Boot started with the deploy profile active. The Kafka auto-configuration was excluded so no broker connection was attempted. HikariCP deferred the database connection so startup completed without a reachable PostgreSQL instance.

The DB_PASSWORD value from Secrets Manager was injected as an environment variable inside the container. You can confirm this by checking the task definition in the ECS console, where the secrets section shows valueFrom pointing to the Secrets Manager ARN.

Your application is now running with secrets injected securely from Secrets Manager. The plaintext password never appears in your task definition, console, or logs. Next up, you will automate this entire deployment with a GitHub Actions CI/CD pipeline that authenticates to AWS using OIDC.

Set Up GitHub Actions OIDC and CI/CD Pipeline

Your application is running in ECS and serving traffic through the ALB. But every time you want to deploy a change, you have to manually build the image, push it to ECR, and update the service.

In this step, you will automate the entire build-test-deploy cycle with GitHub Actions. Instead of storing long-lived AWS credentials in GitHub, you will use OIDC to let GitHub request short-lived tokens directly from AWS on each workflow run.

In this step, get ready to:
  • Create an IAM OIDC identity provider and deploy role for GitHub Actions.
  • Write a GitHub Actions workflow that builds, tests, and deploys on every push to main.
  • Push the workflow and verify the pipeline deploys your app end-to-end.
Create the IAM OIDC identity provider for GitHub

OIDC lets GitHub Actions prove its identity to AWS without any stored credentials. AWS issues a short-lived token per workflow run via sts:AssumeRoleWithWebIdentity.

  • Create the OIDC identity provider in your AWS account by running this command:
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com

What does this command do?

This registers GitHub's OIDC provider with your AWS account. The --url is GitHub's token issuer endpoint. The --client-id-list sets the audience to sts.amazonaws.com, which is required when using the aws-actions/configure-aws-credentials action.

The thumbprint list is optional. AWS uses its trusted CA library to verify GitHub's certificate automatically.

You should see a JSON response containing the OpenIDConnectProviderArn. Note this ARN for use in the trust policy.

Seeing an EntityAlreadyExists error?

This means the OIDC provider already exists in your account from another project. You can continue to the next step. Run aws iam list-open-id-connect-providers to find the existing provider ARN containing token.actions.githubusercontent.com.

Help me troubleshoot OIDC provider creation.

Create the deploy IAM role with trust policy and permissions

The OIDC provider exists, but GitHub Actions still needs an IAM role to assume. The trust policy on this role controls which repository can use it.

  • Create a file called trust-policy.json with the following content:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your-account-id\"]]:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:[[GITHUB_OWNER_REPO=\"your-username/your-repo-name\"]]:*"
        }
      }
    }
  ]
}

What does this trust policy do?

The Federated principal points to your OIDC provider. The Condition block restricts which GitHub tokens can assume this role.

The aud check ensures the token was issued for AWS. The sub check with StringLike and the :* wildcard allows both branch-triggered and environment-triggered runs from your specific repository.

  • Create the IAM role using the trust policy by running:
aws iam create-role \
  --role-name threat-event-deploy-role \
  --assume-role-policy-document file://trust-policy.json

You should see the role details returned in JSON, including the role ARN.

  • Now create a file called deploy-policy.json with the permissions the deploy role needs:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:PutImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload"
      ],
      "Resource": "*"
    }
  ]
}

What are these permissions?

The first statement allows Docker login to ECR. The second grants ECR push permissions so the workflow can upload Docker images to your private registry.

  • Create a second file called deploy-policy-2.json with the ECS deploy, Secrets Manager, and PassRole permissions:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecs:DescribeServices",
        "ecs:UpdateService",
        "ecs:DescribeTaskDefinition",
        "ecs:RegisterTaskDefinition"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your-account-id\"]]:role/ecsTaskExecutionRole"
    }
  ]
}

Why does the role need iam:PassRole?

When the workflow registers a new task definition, it references ecsTaskExecutionRole as the execution role. AWS requires the caller to have iam:PassRole permission to hand off that role to ECS.

  • Merge both policy files into a single JSON file with all statements in one array, then attach it as an inline policy by running:
aws iam put-role-policy \
  --role-name threat-event-deploy-role \
  --policy-name ThreatEventDeployPolicy \
  --policy-document file://deploy-policy.json

Combine both policy files before running

Before running the command above, merge the statements from deploy-policy.json and deploy-policy-2.json into a single JSON file with all five Statement entries in one array. Then pass that combined file to the command.

  • Verify the role was created correctly by running:
aws iam get-role --role-name threat-event-deploy-role --query 'Role.Arn' --output text

You should see the role ARN printed, confirming it exists and is ready for GitHub Actions to assume.

Seeing a MalformedPolicyDocument error?

AWS requires the trust policy for GitHub's OIDC provider to include a condition on token.actions.githubusercontent.com:sub. Double-check your trust policy includes both the aud and sub conditions. Also verify your account ID has no typos in the Federated principal ARN.

Help me fix my OIDC trust policy.

Create the GitHub Actions workflow file

With the IAM plumbing in place, you can now write the workflow that automates your entire deploy process. Every push to main will trigger a build, test, push, and deploy.

  • Create the workflow directory in your project by running:
mkdir -p .github/workflows
  • Create the file .github/workflows/deploy.yml and add the workflow header and environment variables:
name: Deploy to Amazon ECS

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: threat-event-platform
  ECS_SERVICE: threat-event-service
  ECS_CLUSTER: threat-event-cluster
  ECS_TASK_DEFINITION: .aws/task-definition.json
  CONTAINER_NAME: threat-event-platform

What does this configuration do?

  • permissions: id-token: write allows the workflow to request an OIDC token from GitHub. Without this, the AWS credentials action cannot authenticate.
  • The env block centralizes resource names so you can change them in one place instead of editing every step.
  • Below the env block, add the job definition with checkout, Java setup, tests, and AWS credentials steps:
jobs:
  deploy:
    name: Build, Test, and Deploy
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Run tests
        run: mvn verify -B

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::[[AWS_ACCOUNT_ID="your-account-id"]]:role/threat-event-deploy-role
          aws-region: ${{ env.AWS_REGION }}

What do these steps do?

  • mvn verify -B runs the full test suite. If tests fail, the pipeline stops before deploying a broken image.
  • aws-actions/configure-aws-credentials@v4 exchanges the GitHub OIDC token for temporary AWS credentials via role-to-assume. No long-lived secrets stored anywhere.
  • Add the ECR login, image build, task definition render, and deploy steps below the Configure AWS credentials step:
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          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: Fill in the new image ID in the Amazon ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ env.ECS_TASK_DEFINITION }}
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

What do these deploy steps do?

  • The build step tags the image with ${{ github.sha }}, making every image traceable to its exact commit. No two deploys share the same tag.
  • aws-actions/amazon-ecs-render-task-definition@v1 injects the new image URI into your checked-in task definition JSON without modifying the file on disk.
  • aws-actions/amazon-ecs-deploy-task-definition@v2 registers the updated task definition and updates the ECS service. The wait-for-service-stability: true flag keeps the step running until the new task passes health checks.
  • Save .github/workflows/deploy.yml.

✔️ Awesome, I've got everything!

Double-check you saved the file at exactly .github/workflows/deploy.yml in your project root.

ⓧ I'd like to double check the full code

Scroll up and confirm you have all three YAML chunks in order: the header/env block, the job/steps block (through Configure AWS credentials), and the ECR/deploy block. They combine into one continuous YAML file.

Key things to verify: permissions is at the top level (not inside jobs), role-to-assume contains your actual AWS account ID, and the indentation uses spaces (not tabs).

Why OIDC instead of access keys?

Traditional CI/CD stores long-lived AWS access keys in GitHub Secrets. If those keys leak or are never rotated, anyone with them has permanent access to your AWS account.

OIDC tokens are issued fresh per workflow run and expire within minutes. There are zero stored credentials to leak, rotate, or manage. This is the recommended approach from both AWS and GitHub for production pipelines.

Push the workflow and verify the pipeline

The workflow triggers on push to main. Pushing the new file will kick off your first automated deployment.

Create the GitHub environment first

The workflow references environment: production. Before pushing, go to your GitHub repo, click Settings, then Environments, then click New environment. Name it production and click Configure environment. No additional settings are needed.

  • Stage, commit, and push your changes by running:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD deploy workflow with OIDC"
git push origin main

Before you check the result, what do you think will happen? Will the workflow succeed on the first try, or might something in the OIDC handshake fail?

  • Open your GitHub repository in your browser.
  • Click the Actions tab at the top of the repository.
  • Click on the running workflow (it shows your commit message as the title).
  • Click the Build, Test, and Deploy job to watch each step execute in real time.

The workflow takes 3-5 minutes. You should see every step complete with a green checkmark.

  • Once the workflow succeeds, confirm the ECS task is running the new image by running:
aws ecs describe-task-definition \
  --task-definition threat-event-platform \
  --query 'taskDefinition.containerDefinitions[0].image' --output text

The output shows your ECR image URI ending in a long hexadecimal string. That string is the Git commit SHA of the push you just made. This proves the pipeline built, tagged, and deployed the exact code from your commit.

Workflow failed at Configure AWS credentials?

  • Check the trust policy: make sure your GitHub username and repo name in the sub condition match exactly (case-sensitive).
  • Make sure the production environment exists in your GitHub repo settings. Without it, the job cannot issue a token scoped to that environment.
  • Verify the account ID in your workflow's role-to-assume matches the account ID in your trust policy's Federated principal.

Help me debug my GitHub Actions OIDC authentication to AWS.

Your pipeline is fully automated. Every push to main triggers a build, runs your test suite, and deploys a fresh image to ECS with zero stored credentials. Next up, you will test what happens when a bad deployment gets past the build step.

Test Deployment Rollback and Verify Circuit Breaker

Your CI/CD pipeline builds, tests, and deploys on every push to main. But a pipeline that only deploys is incomplete.

Production systems need proof that bad deployments are automatically detected and reverted. In this step, you will intentionally break your deployment to validate the circuit breaker you configured. You will push a broken health check, watch ECS catch it, and confirm automatic rollback to the previous healthy version.

In this step, get ready to:
  • Deploy a broken image that fails health checks to trigger the deployment circuit breaker.
  • Verify ECS automatically rolled back to the previous healthy task definition revision.
  • Restore the correct health check and confirm the pipeline recovers.
Intentionally break the health check

The Dockerfile health check currently hits /actuator/health. By pointing it at a nonexistent endpoint, the container will start but never pass its health check. ECS will keep trying to launch new tasks, and after 3 consecutive failures the circuit breaker will trigger an automatic rollback.

  • In your Dockerfile, find the HEALTHCHECK line:
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1
  • Replace /actuator/health with /actuator/nonexistent, so the line now looks like this:
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/nonexistent || exit 1

Why this specific break?

The container still starts and runs Java normally. Only the health check fails because /actuator/nonexistent returns a 404. This simulates a real-world scenario where an app deploys but is unhealthy.

ECS uses the health check to decide whether the task is ready to receive traffic. Three consecutive failures (with a 30-second start period and 15-second interval) means rollback triggers within 2-4 minutes.

Before you push this, what do you think will happen? The image will build and push successfully to ECR, but what happens when ECS tries to run a container that never passes its health check?

  • Commit and push the broken health check by running these commands:
git add Dockerfile
git commit -m "Break health check to test circuit breaker"
git push origin main
  • Open your repository on GitHub and click the Actions tab.
  • Watch the workflow run. The build, test, and image push steps will all succeed.
  • The final Deploy Amazon ECS task definition step will eventually fail because wait-for-service-stability: true times out when the new task never becomes healthy.

What should I see?

The GitHub Actions workflow will show a red X on the deploy step after several minutes. This is expected. The image was pushed to ECR, ECS attempted to deploy it, the health check failed repeatedly, and the circuit breaker triggered rollback.

Workflow still running after 10 minutes?

The wait-for-service-stability timeout defaults to 10 minutes in the ECS deploy action. If it is still running, give it the full timeout. The workflow will fail once ECS reports the deployment as unsuccessful.

help me understand why my ECS deployment is taking so long to fail

Observe and verify the rollback

Once the GitHub Actions workflow shows a failure on the deploy step, the circuit breaker has done its job. Now confirm ECS rolled back to your previous healthy revision using the AWS CLI.

  • Confirm the deployment failed and rollback occurred by running:
aws ecs describe-services --cluster threat-event-cluster --services threat-event-service --query 'services[0].deployments[*].{status:status,rolloutState:rolloutState,taskDef:taskDefinition,desiredCount:desiredCount,runningCount:runningCount}' --output table

You should see a table with two rows. One deployment shows rolloutState: FAILED (the broken image). The other shows rolloutState: COMPLETED (the previous healthy version that ECS rolled back to).

  • Verify the circuit breaker triggered by checking the failure reason:
aws ecs describe-services --cluster threat-event-cluster --services threat-event-service --query 'services[0].deployments[?rolloutState==`FAILED`].rolloutStateReason' --output text

You should see a message indicating the circuit breaker detected the failure.

  • Confirm the healthy task is running on the previous revision:
aws ecs list-tasks --cluster threat-event-cluster --service-name threat-event-service --query 'taskArns' --output text | xargs -I {} aws ecs describe-tasks --cluster threat-event-cluster --tasks {} --query 'tasks[0].{taskDef:taskDefinitionArn,lastStatus:lastStatus,healthStatus:healthStatus}' --output table

You should see lastStatus: RUNNING and healthStatus: HEALTHY. The task definition ARN points to the previous revision, not the broken one.

  • Verify the ALB target is healthy by running:
aws elbv2 describe-target-health --target-group-arn [[TG_ARN="your target group ARN from the threat-event-tg target group"]] --query 'TargetHealthDescriptions[*].{target:Target.Id,health:TargetHealth.State}' --output table

You should see health: healthy confirming the load balancer is still routing to a working container.

Find your target group ARN

If you do not have your target group ARN saved, retrieve it by running:

aws elbv2 describe-target-groups --names threat-event-tg --query 'TargetGroups[0].TargetGroupArn' --output text
  • Check CloudWatch for the failed task's error logs:
aws logs filter-log-events --log-group-name /ecs/threat-event-platform --filter-pattern "ERROR" --start-time $(date -v-10M +%s000) --query 'events[*].message' --output text

Running on Linux?

The date -v-10M flag is macOS-specific. On Linux, replace $(date -v-10M +%s000) with $(date -d '10 minutes ago' +%s000).

You should see error entries from the broken container that attempted to start with the invalid health check endpoint.

What just happened?

ECS tried to launch a new task with your broken image. The container started, but the health check at /actuator/nonexistent returned 404 every 15 seconds. After 3 retries, ECS marked the task as unhealthy and tried again.

After 3 total failed task launches, the circuit breaker triggered and reverted the service to the last known-good task definition revision. Your ALB never lost its healthy target because ECS kept the previous task running throughout.

Fix the Dockerfile and verify recovery

The circuit breaker did its job. Now restore the correct health check and confirm the pipeline can recover cleanly from a previously failed deployment.

  • In your Dockerfile, find the broken HEALTHCHECK line:
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/nonexistent || exit 1
  • Replace /actuator/nonexistent with /actuator/health, restoring the original working line:
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1
  • Save Dockerfile.
  • Commit and push the fix by running:
git add Dockerfile
git commit -m "Restore health check after rollback test"
git push origin main
  • Return to the Actions tab in GitHub and watch the new workflow run complete successfully.
  • Wait for the workflow to show a green checkmark on all steps, including the deploy step.

What do you expect the ECS service state to look like now that a healthy image has been deployed after a rollback?

  • Verify the pipeline recovered and the service is stable by running:
aws ecs describe-services --cluster threat-event-cluster --services threat-event-service --query 'services[0].deployments[?status==`PRIMARY`].{rolloutState:rolloutState,runningCount:runningCount}' --output table

You should see rolloutState: COMPLETED and runningCount: 1. Your service is back to a healthy steady state with the fixed image.

Still seeing the old failed deployment in the table?

ECS keeps a record of recent deployments. You may see both the new COMPLETED deployment and the earlier FAILED one in the output. The key is that the PRIMARY deployment shows COMPLETED with runningCount: 1.

help me understand the ECS deployment states after my rollback recovery

✔️ Awesome, I've got everything!

Great. Your circuit breaker test is complete and the pipeline has recovered. Double check you have saved your Dockerfile with the correct /actuator/health endpoint restored.

ⓧ I'd like to double check the full code

Your Dockerfile should look exactly like this after the fix:

FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENV SPRING_PROFILES_ACTIVE=deploy
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]

You have now proven that your deployment pipeline is not just a one-way street. It can detect broken deployments and automatically revert to a known-good state without any manual intervention. That is the safety net every production system needs.

Secret mission

Portfolio README and Manual Rollback Workflow

A deployment pipeline without documentation is invisible to hiring managers. In this bonus challenge, you will create a portfolio-ready README with an architecture diagram and live deploy badge, write a Design Decisions section that articulates production trade-offs, and build a manual rollback workflow that gives you an emergency escape hatch beyond automated circuit breaker detection.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses paid AWS services that charge per hour while running.

Cost warning

Leaving your ECS Fargate task and Application Load Balancer running costs approximately $1.30 per day. If you are finished testing, choose Pause or Delete below to stop charges.

Resources you used:

  • ECS Fargate service (threat-event-service) and cluster (threat-event-cluster).
  • Application Load Balancer (threat-event-alb) with listener and target group (threat-event-tg).
  • ECR repository (threat-event-platform) with Docker images.
  • Secrets Manager secret (threat-event-platform/db-password).
  • CloudWatch Log Group (/ecs/threat-event-platform).
  • IAM roles (ecsTaskExecutionRole, threat-event-deploy-role), policies, and OIDC provider.
  • Security groups for ALB and ECS tasks.
  • ECS task definition revisions (threat-event-platform family).

Keep everything running

No action needed. Choose this if you are still actively demonstrating your deployment or showing it as a portfolio piece.

  • Your ALB URL remains publicly accessible and serves the health endpoint.
  • Estimated cost: approximately $1.30 per day (Fargate + ALB combined).
  • ECR storage costs $0.10 per GB per month for stored images.
  • Secrets Manager charges $0.40 per month for the stored secret.

Pause - I'll come back to this later

Shut down your running Fargate tasks to stop compute charges while keeping all infrastructure in place.

  • Set the ECS service desired count to 0 by running:
aws ecs update-service --cluster threat-event-cluster --service threat-event-service --desired-count 0
  • This stops Fargate charges immediately. No tasks will run until you set the count back to 1.
  • The ALB still charges approximately $0.55 per day even with no targets.
  • To resume later, run the same command with --desired-count 1.

Delete - I don't want to use this again

Remove all project resources permanently. Run these commands in order. Some resources depend on others being deleted first.

Delete the ECS service, task definitions, and cluster:

# Delete ECS service (force to skip draining)
aws ecs update-service --cluster threat-event-cluster --service threat-event-service --desired-count 0
aws ecs delete-service --cluster threat-event-cluster --service threat-event-service --force

# Deregister all task definition revisions
for rev in $(aws ecs list-task-definitions --family-prefix threat-event-platform --query 'taskDefinitionArns[]' --output text); do
  aws ecs deregister-task-definition --task-definition $rev
done

# Delete ECS cluster
aws ecs delete-cluster --cluster threat-event-cluster

Delete the ALB, listener, and target group:

ALB_ARN=$(aws elbv2 describe-load-balancers --names threat-event-alb --query 'LoadBalancers[0].LoadBalancerArn' --output text)
LISTENER_ARN=$(aws elbv2 describe-listeners --load-balancer-arn $ALB_ARN --query 'Listeners[0].ListenerArn' --output text)
aws elbv2 delete-listener --listener-arn $LISTENER_ARN
aws elbv2 delete-load-balancer --load-balancer-arn $ALB_ARN
# Wait for ALB to fully delete before removing target group
sleep 30
TG_ARN=$(aws elbv2 describe-target-groups --names threat-event-tg --query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 delete-target-group --target-group-arn $TG_ARN

Delete ECR, Secrets Manager, and CloudWatch resources:

# Delete ECR repository and all images
aws ecr delete-repository --repository-name threat-event-platform --force

# Delete Secrets Manager secret (force immediate deletion)
aws secretsmanager delete-secret --secret-id threat-event-platform/db-password --force-delete-without-recovery

# Delete CloudWatch log group
aws logs delete-log-group --log-group-name /ecs/threat-event-platform

Delete IAM roles, policies, and the OIDC provider:

# Delete task execution role
aws iam detach-role-policy --role-name ecsTaskExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam delete-role-policy --role-name ecsTaskExecutionRole --policy-name SecretsManagerReadPolicy
aws iam delete-role --role-name ecsTaskExecutionRole

# Delete deploy role and its policy
aws iam detach-role-policy --role-name threat-event-deploy-role --policy-arn arn:aws:iam::[[AWS_ACCOUNT_ID="your-account-id"]]:policy/ThreatEventDeployPolicy
aws iam delete-policy --policy-arn arn:aws:iam::[[AWS_ACCOUNT_ID="your-account-id"]]:policy/ThreatEventDeployPolicy
aws iam delete-role --role-name threat-event-deploy-role

# Delete OIDC provider
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn arn:aws:iam::[[AWS_ACCOUNT_ID="your-account-id"]]:oidc-provider/token.actions.githubusercontent.com

Delete security groups created for ALB and ECS tasks:

aws ec2 delete-security-group --group-id [[ALB_SG_ID="your-alb-security-group-id"]]
aws ec2 delete-security-group --group-id [[ECS_SG_ID="your-ecs-task-security-group-id"]]

Verify everything is deleted by running these checks:

# Confirm no ECS resources
aws ecs list-clusters --query 'clusterArns' --output text
aws ecs list-task-definitions --family-prefix threat-event-platform --query 'taskDefinitionArns' --output text

# Confirm no load balancer or target group
aws elbv2 describe-load-balancers --names threat-event-alb 2>&1 | grep -q "LoadBalancerNotFound" && echo "ALB deleted"
aws elbv2 describe-target-groups --names threat-event-tg 2>&1 | grep -q "TargetGroupNotFound" && echo "TG deleted"

# Confirm ECR empty
aws ecr describe-repositories --repository-names threat-event-platform 2>&1 | grep -q "RepositoryNotFoundException" && echo "ECR deleted"

# Confirm secret deleted
aws secretsmanager describe-secret --secret-id threat-event-platform/db-password 2>&1 | grep -q "ResourceNotFoundException" && echo "Secret deleted"
# Confirm log group deleted
aws logs describe-log-groups --log-group-name-prefix /ecs/threat-event-platform --query 'logGroups' --output text | grep -q "threat-event-platform" || echo "Log group deleted"

# Confirm IAM roles removed
aws iam get-role --role-name threat-event-deploy-role 2>&1 | grep -q "NoSuchEntity" && echo "Deploy role deleted"
aws iam get-role --role-name ecsTaskExecutionRole 2>&1 | grep -q "NoSuchEntity" && echo "Execution role deleted"

# Confirm OIDC provider removed
aws iam list-open-id-connect-providers --query 'OpenIDConnectProviderList[*].Arn' --output text | grep -q "token.actions.githubusercontent.com" || echo "OIDC provider deleted"

Each verification command should print a confirmation message. If any resource still exists, re-run the corresponding delete command above.

Nice Work!

Nice Work!

You just took a containerized Spring Boot application from a local Docker Compose setup to a fully automated, production-grade AWS deployment. Your threat-event platform is live behind a load balancer, deployed by a pipeline that needs zero stored credentials.

You've learned how to:

  • Deploy a Spring Boot application to AWS ECS Fargate behind an Application Load Balancer, with a CI/CD pipeline that authenticates via OIDC and tags every image with the Git commit SHA for full traceability.
  • Implement an ECS deployment circuit breaker that automatically rolls back failed deployments, verified by intentionally pushing a broken image and watching the system recover on its own.
  • Configure production-grade infrastructure with Secrets Manager for secure credential injection, CloudWatch for structured logging, and Actuator health checks that ECS uses to determine container readiness.
  • Document the architecture in a portfolio-ready README with a live deploy-status badge, and create a manual rollback workflow for emergencies beyond automated detection (Secret Mission).

Ready to quiz yourself?