Deploy Java + Angular to AWS ECS

Build a production CI/CD pipeline from Docker to ECS Fargate with an ALB.

Introduction

30 Second Summary

Every developer has built something that works locally. The challenge is getting it running reliably in production with proper secrets management, automated testing, and rollback if something breaks.

In this project, you will build a complete deployment pipeline that takes a Java and Angular app from local Docker containers to running on AWS ECS Fargate behind a load balancer. You'll configure every layer the way a senior developer would in a real production environment.

What You'll Build

A production-grade CI/CD pipeline that containerizes a Spring Boot backend and Angular frontend, then deploys them to AWS ECS Fargate with automated testing, secrets management, and automatic rollback on failure.

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

  • Multi-stage Docker builds optimized for Java (layered JARs) and Angular (Nginx), with Docker Compose for local development.
  • A working GitHub Actions CI/CD pipeline that tests, builds, and pushes Docker images to AWS ECR on every push to main.
  • A live containerized application running on ECS Fargate behind an Application Load Balancer with deployment circuit breakers for automatic rollback.
  • Secret Mission: Blue/green deployment strategy with a test listener for zero-downtime releases.

Are there any prerequisites?

This project assumes you're familiar with Java (Spring Boot) and Angular development. You should also have basic Git knowledge.

Create Your AWS Account and IAM User

Before you can deploy anything to the cloud, you need somewhere to deploy it. AWS provides the infrastructure where your containers will run in production.

In this step, you'll create an AWS account, set up programmatic access so your CLI and CI/CD pipeline can talk to AWS, and create a GitHub repository to house your code.

In this step, get ready to:
  • Create an AWS account with free credits.
  • Create an IAM user with programmatic access for the CLI and GitHub Actions.
  • Create a GitHub repository for your project code and CI/CD workflow.
Create a new AWS account

AWS gives new accounts $100 in credits at sign-up, with up to $100 more as you explore services. These credits are valid for 6 months and will cover everything in this project.

  • Open aws.amazon.com/free in your browser.
  • Click Create a Free Account and follow the sign-up wizard.
  • You'll need to provide an email address, create a password, and enter payment information (for identity verification only).
  • Once sign-up completes, sign in to the AWS Management Console to confirm your account is active.

Why a new AWS account?

A fresh account guarantees you get the full $100-200 in credits. It also keeps your learning resources isolated from any existing production infrastructure.

Create an IAM user and configure the AWS CLI

Your root account has unrestricted access to everything in AWS. For day-to-day CLI work and CI/CD, you'll create a separate IAM user with its own access keys.

  • In the AWS Console, search for IAM in the top search bar and open the IAM service.
  • In the left navigation pane, choose Users.
  • Click Create user.
  • On the Specify user details page, enter a user name (e.g. deploy-user) and click Next.
  • On the Set permissions page, select Attach policies directly.
  • Search for AdministratorAccess and select the checkbox next to it.
  • Click Next, then click Create user.

Why AdministratorAccess for a learning project?

In production, you would follow the principle of least privilege and grant only the specific permissions needed. For this learning project, AdministratorAccess simplifies setup so you can focus on the deployment pipeline without getting blocked by permission errors.

Now generate an access key pair so the AWS CLI can authenticate as this user.

  • Click on your new user's name to open their details page.
  • Select the Security credentials tab.
  • In the Access keys section, click Create access key.
  • On the Access key best practices & alternatives page, select Command Line Interface (CLI) as your use case.
  • Check the confirmation checkbox at the bottom, then click Next.
  • Click Create access key.
  • On the Retrieve access key page, click Download .csv file to save your credentials. Store this file securely.
  • Click Done.

Save your credentials now

This is the only time AWS will show you the secret access key. If you lose it, you'll need to delete the key pair and create a new one. Keep the downloaded CSV in a secure location.

Now configure your local AWS CLI with these credentials.

  • Open your terminal and configure the AWS CLI by running this command:
aws configure

The CLI will prompt you for four values. Enter them as follows:

  • AWS Access Key ID: paste the access key ID from your downloaded CSV.
  • AWS Secret Access Key: paste the secret access key from your downloaded CSV.
  • Default region name: enter us-east-1.
  • Default output format: enter json.
  • Verify your configuration works by running this command:
aws sts get-caller-identity

You should see a JSON response containing your Account ID, User ARN, and User ID. This confirms the AWS CLI can authenticate successfully.

Seeing an error?

If you see a credentials error, double-check that you pasted the access key ID and secret key correctly with no extra spaces. Run aws configure again to re-enter them.

If you see a region error, make sure you entered us-east-1 exactly (lowercase, with the hyphen).

Help me troubleshoot my AWS CLI setup

Create a GitHub repository

Your code and CI/CD workflow will live in a GitHub repository. GitHub Actions will use this repository to automatically build and deploy your containers on every push.

  • Go to github.com and sign in (or create an account if you don't have one).
  • Click the + icon in the top right corner and select New repository.
  • Enter a repository name (e.g. myapp-deployment).
  • Leave the repository set to Public (or choose Private if you prefer).
  • Do not initialize with a README, .gitignore, or license. You'll push your own files later.
  • Click Create repository.

Why an empty repository?

You'll be creating all your project files locally (including a custom .gitignore) in the next step. Starting with an empty repo avoids merge conflicts when you push for the first time.

Your AWS account is live, your CLI is configured, and your GitHub repo is ready. Next up, you'll configure the project structure the way a senior dev would.

Configure the Project Like a Senior Dev

Your AWS account and GitHub repo are ready. Now it's time to set up the project structure the way experienced engineers do in production environments.

A well-configured project separates secrets from source code, makes dependencies obvious to new teammates, and keeps Docker builds fast by excluding unnecessary files. In this step, you'll create that foundation.

In this step, get ready to:
  • Set up the monorepo directory structure with a comprehensive .gitignore.
  • Externalize application configuration using Spring Boot profiles.
  • Create environment files and .dockerignore files for lean builds.
Create the monorepo structure and .gitignore

A monorepo holds both the backend and frontend in a single repository. This simplifies the CI/CD pipeline because one push triggers builds for everything.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Terminal, then press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your GitHub repository by running this command:
git clone [[YOUR_REPO_URL="https://github.com/your-username/your-repo.git"]]
  • Move into the cloned repository and create the monorepo directory structure by running these commands:
cd [[YOUR_REPO_NAME="your-repo-folder-name"]]
mkdir -p backend/src/main/resources
mkdir -p frontend
  • Confirm the directories exist by running:
ls

You should see backend and frontend listed in the output.

Don't see the folders?

Make sure you ran the mkdir -p command from inside your cloned repository folder. Run pwd to check your current location.

Help me figure out what went wrong with my directory setup

Now open your project in a code editor. You'll use it to create and edit files for the rest of this step.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select your cloned repository folder on the Desktop, then click Open.

Every production project needs a .gitignore to keep build artifacts, IDE files, and secrets out of version control.

  • Create a new file called .gitignore in the root of your project by right-clicking in the VS Code Explorer sidebar and selecting New File.
  • Name it .gitignore and paste the following content:
# Java / Maven
backend/target/
*.class
*.jar
*.war

# Node / Angular
frontend/node_modules/
frontend/dist/
frontend/.angular/

# IDE
.idea/
*.iml
.vscode/
*.swp
*.swo

# Environment files (NEVER commit secrets)
.env
.env.local
.env.*.local

# Docker
docker-compose.override.yml

# OS
.DS_Store
Thumbs.db

# Logs
*.log

What does this .gitignore do?

Each section targets a category of files that should never be committed:

  • backend/target/ excludes compiled Java bytecode and packaged JARs that Maven produces.
  • frontend/node_modules/ excludes thousands of dependency files that npm install recreates.
  • .env keeps your local secrets (database passwords, API keys) out of Git history entirely.
  • docker-compose.override.yml is for personal local overrides that differ between teammates.
  • Save .gitignore.

Next, you'll create the Spring Boot configuration file that externalizes all environment-specific values.

  • In the VS Code Explorer sidebar, expand backend/src/main/resources.
  • Create a new file called application.yml inside that folder.
  • Paste the following content:
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:myapp}
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:secret}

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: always

server:
  port: 8080

What is the ${VARIABLE:default} syntax?

Spring Boot's property placeholder syntax reads values from environment variables at runtime. The part after the colon is a fallback default. For example, ${DB_HOST:localhost} means: use the DB_HOST environment variable if it exists, otherwise fall back to localhost.

This lets you run the same code locally (with defaults) and in production (with real values injected by ECS or Docker Compose) without changing a single line.

  • Save application.yml.

✔️ Awesome, I've got everything!

Great. Make sure both .gitignore and application.yml are saved.

ⓧ I'd like to double check the full code

Here is the complete .gitignore:

# Java / Maven
backend/target/
*.class
*.jar
*.war

# Node / Angular
frontend/node_modules/
frontend/dist/
frontend/.angular/

# IDE
.idea/
*.iml
.vscode/
*.swp
*.swo

# Environment files (NEVER commit secrets)
.env
.env.local
.env.*.local

# Docker
docker-compose.override.yml

# OS
.DS_Store
Thumbs.db

# Logs
*.log

Here is the complete application.yml:

spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:myapp}
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:secret}

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: always

server:
  port: 8080
Create environment files and .dockerignore

Your application.yml references environment variables like DB_PASSWORD. For local development, those values come from a .env file that Docker Compose reads.

  • Create a new file called .env in the project root (the same level as .gitignore).
  • Paste the following content:
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USERNAME=postgres
DB_PASSWORD=secret

# Spring Profile
SPRING_PROFILES_ACTIVE=dev

# AWS (for local ECR push testing)
AWS_REGION=us-east-1
AWS_ACCOUNT_ID=[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]

Why is .env gitignored?

This file holds real values your application uses at runtime. Even though the local dev password (secret) is not sensitive, the pattern matters. In a real project, this file would contain production credentials that must never reach version control.

Your .gitignore already excludes .env, so Git will never track it.

  • Save .env.

Teammates who clone your repo won't have a .env file. They need to know which variables to set. That's what .env.example solves.

  • Create a new file called .env.example in the project root.
  • Paste the following content:
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USERNAME=postgres
DB_PASSWORD=your-password-here

# Spring Profile
SPRING_PROFILES_ACTIVE=dev

# AWS (for local ECR push testing)
AWS_REGION=us-east-1
AWS_ACCOUNT_ID=your-account-id-here

What is .env.example for?

Unlike .env, this file IS committed to Git. It shows every variable the project needs without exposing actual secrets. New teammates copy it to .env and fill in their own values.

  • Save .env.example.

Next, you'll create .dockerignore files. These keep unnecessary files out of the Docker build context, making image builds faster.

  • Create a new file called .dockerignore inside the backend/ directory.
  • Paste the following content:
target/
.git/
.gitignore
.env
.idea/
*.iml
README.md
  • Save backend/.dockerignore.
  • Create a new file called .dockerignore inside the frontend/ directory.
  • Paste the following content:
node_modules/
dist/
.angular/
.git/
.gitignore
.env
README.md
*.md

Why do you need .dockerignore files?

When Docker builds an image, it copies the entire directory (the "build context") into the build engine. Without a .dockerignore, that includes node_modules/ (hundreds of MBs) and target/ (compiled JARs you'll rebuild inside the container anyway).

Excluding them shrinks the build context and speeds up every docker build dramatically.

  • Save frontend/.dockerignore.

Now push all your configuration files to GitHub.

  • Switch back to your terminal and commit your project files by running these commands:
git add .
git commit -m "Add project structure, .gitignore, config, and env files"
git push origin main

What does this push include?

Because .gitignore is already in place, git add . only stages the files you want committed: .gitignore, .env.example, application.yml, and both .dockerignore files. Your .env with real values stays local.

You should see output confirming the push to origin/main.

Push failed or rejected?

If you see a "failed to push" error, make sure your GitHub repository is empty (no README or license file was created during setup). If it has an initial commit, pull first with git pull origin main --allow-unrelated-histories and then push again.

Help me fix my git push

✔️ Awesome, I've got everything!

Great. Double check you've saved all files and your push succeeded.

ⓧ I'd like to double check the full code

Here is the complete .env.example:

# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USERNAME=postgres
DB_PASSWORD=your-password-here

# Spring Profile
SPRING_PROFILES_ACTIVE=dev

# AWS (for local ECR push testing)
AWS_REGION=us-east-1
AWS_ACCOUNT_ID=your-account-id-here

Here is the complete backend/.dockerignore:

target/
.git/
.gitignore
.env
.idea/
*.iml
README.md

Here is the complete frontend/.dockerignore:

node_modules/
dist/
.angular/
.git/
.gitignore
.env
README.md
*.md
Why .env files don't belong in production

You just set up a clean local development workflow. But it's worth understanding why this pattern changes in production.

Why not use .env in production containers?

In local dev, .env files are convenient. Docker Compose reads them and injects values into containers. That's fine when "production" is your laptop.

In real production, environment variables set via .env or docker run -e are visible in docker inspect output and can leak into container logs. Anyone with access to the host can read them.

The production solution is AWS Secrets Manager. Secrets are stored encrypted and injected directly into the ECS task definition using the secrets parameter. They never appear as environment variables in the container. You'll set this up in a later step.

Your project is configured the way a senior dev would set it up. Next, you'll containerize the Spring Boot backend with a multi-stage Dockerfile optimized for fast rebuilds.

Dockerize the Spring Boot Backend

Your project now has a clean configuration setup with secrets separated from source code and Spring Boot profiles ready for different environments. The next goal is to package this backend into a Docker container that runs identically everywhere.

A naive Docker image would bundle the entire JDK, Maven cache, and source code alongside your compiled app. Multi-stage builds solve this by using one stage to compile and a separate, minimal stage to run.

In this step, get ready to:
  • Write a multi-stage Dockerfile for the Spring Boot backend.
  • Build and run the Docker image locally.
  • Verify the health endpoint and review image layer sizes.
Write the multi-stage Dockerfile

A multi-stage Docker build uses separate stages for compiling code and running it. The build stage includes heavy tools (JDK, Maven) that the runtime stage doesn't need.

  • Create a new file called Dockerfile inside your backend/ folder.
  • Paste the following code into backend/Dockerfile:
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build

# Copy Maven wrapper and pom.xml first for dependency caching
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline

# Copy source and build
COPY src ./src
RUN ./mvnw package -DskipTests

# Extract layered JAR
RUN java -Djarmode=tools -jar target/*.jar extract --layers --destination target/extracted

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy layers in order of change frequency (least to most)
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/application/ ./

USER appuser
EXPOSE 8080

ENTRYPOINT ["java", "-jar", "application.jar"]

What does this Dockerfile do?

Stage 1 (builder) handles compilation:

  • eclipse-temurin:21-jdk-alpine is the official Eclipse Temurin OpenJDK image, which includes the full JDK needed to compile Java.
  • Copying pom.xml first and running dependency:go-offline downloads all Maven dependencies into a cached Docker layer. Source code changes won't invalidate this layer.
  • The java -Djarmode=tools command extracts the Spring Boot JAR into four separate layer directories: dependencies, spring-boot-loader, snapshot-dependencies, and application.

Stage 2 (runtime) keeps only what's needed to run:

  • eclipse-temurin:21-jre-alpine is a minimal image with just the Java Runtime Environment.
  • A non-root user (appuser) runs the application for security.
  • The four COPY commands bring in each extracted layer separately. Docker caches each one independently, so only the changed layer rebuilds.
  • Save backend/Dockerfile.
  • You should see Dockerfile appear in the backend/ folder in your editor's file explorer.

Don't see the file in your editor?

  • Make sure you created the file inside the backend/ directory, not the project root.
  • The filename must be exactly Dockerfile with a capital D and no file extension.
  • help me find where to create my Dockerfile

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build

# Copy Maven wrapper and pom.xml first for dependency caching
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline

# Copy source and build
COPY src ./src
RUN ./mvnw package -DskipTests

# Extract layered JAR
RUN java -Djarmode=tools -jar target/*.jar extract --layers --destination target/extracted

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy layers in order of change frequency (least to most)
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/application/ ./

USER appuser
EXPOSE 8080

ENTRYPOINT ["java", "-jar", "application.jar"]
Build and test the image locally

With the Dockerfile in place, you can now build an image and spin up a container to verify everything works.

  • Build the Docker image by running this command from your project root:
docker build -t myapp-backend ./backend

What does this command do?

  • docker build reads the Dockerfile and executes each instruction to create an image.
  • -t myapp-backend tags the image with the name myapp-backend so you can reference it later.
  • ./backend points Docker to the backend/ directory as the build context. Your .dockerignore file keeps the context lean.
  • Wait for the build to finish. The first run downloads base images and Maven dependencies, so it may take a few minutes.
  • You should see Successfully tagged myapp-backend:latest (or a build ID) at the end of the output.

Build failing?

  • Make sure Docker Desktop is running. You can check by looking for the Docker icon in your system tray.
  • Verify your backend/ folder has a pom.xml, a mvnw file, a .mvn/ directory, and a src/ directory. The Dockerfile expects all of these.
  • If you see a permission denied error on mvnw, the chmod +x mvnw line in the Dockerfile handles this inside the container. Check that you copied the Dockerfile exactly.
  • help me debug my Docker build error
  • Run the container by executing this command:
docker run -p 8080:8080 myapp-backend
  • You should see Spring Boot startup logs in your terminal, ending with a line that says the application started on port 8080.
  • Open a new terminal tab or window, then verify the health endpoint by running:
curl http://localhost:8080/actuator/health
  • You should see a JSON response containing "status":"UP". This confirms your containerized backend is running and healthy.
  • Press Ctrl+C in the terminal where the container is running to stop it.

Health endpoint not responding?

  • Check that the container is still running. If it exited immediately, look at the logs for an error (often a missing database connection). For this verification, the health endpoint should still respond even without a database.
  • Make sure nothing else is using port 8080 on your machine. Stop any other local servers before running the container.
  • Verify you used -p 8080:8080 in the run command. Without the port mapping, the container's port isn't exposed to your host.
  • help me troubleshoot my Docker container's health endpoint
Review the layered JAR optimization

The layered JAR approach is a key interview talking point for Docker and Java deployments. It exploits how Docker caches image layers to dramatically speed up rebuilds.

Why does layering matter for CI build times?

Docker caches each layer independently. When you rebuild an image, Docker skips any layer whose inputs haven't changed.

In a Spring Boot app, dependencies (dependencies/) change rarely. Application code (application/) changes on every commit. By separating them into distinct layers, a code-only change only rebuilds the small application/ layer. This can reduce CI build times from around 3 minutes to around 15 seconds for code-only changes.

  • Inspect your image's layer sizes by running:
docker history myapp-backend
  • You should see a table of layers with their sizes. Notice that the dependency layers are large (tens of MB) while the application layer is small (a few KB to a few MB).
  • This is the payoff. When you change only your Java source code and rebuild, Docker reuses the cached dependency layers and only recreates the tiny application layer.

Interview framing for layered JARs

If asked about Docker optimization in an interview, explain it in terms of the feedback loop. Developers push code constantly. If every push triggers a 3-minute image rebuild in CI, that's wasted time and compute. Layered JARs make rebuilds proportional to the size of the change, not the size of the project.

Your backend is containerized and running behind a health check. Next up, you'll Dockerize the Angular frontend with Nginx to serve your production static files.

Dockerize the Angular Frontend

Your backend is containerized and responding to health checks. Now you need to do the same for the Angular frontend.

Angular compiles down to static HTML, CSS, and JavaScript files. These files need a web server to serve them in production. You'll use Nginx as that web server, configured to handle client-side routing and proxy API requests to the backend.

In this step, get ready to:
  • Write a custom Nginx configuration for routing and API proxying.
  • Create a multi-stage Dockerfile for the Angular frontend.
  • Build and test the frontend container locally.
Write the Nginx configuration and frontend Dockerfile

Angular uses client-side routing, which means all URL paths need to resolve to index.html. Without custom configuration, Nginx would return a 404 for any route that isn't a physical file on disk.

  • Create a file called nginx.conf inside your frontend/ directory by running this command from your project root:
touch frontend/nginx.conf
  • Open frontend/nginx.conf in your editor and paste the following configuration:
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # Handle Angular client-side routing
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to backend
    location /api/ {
        proxy_pass http://backend:8080/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Cache static assets
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

What does this configuration do?

  • try_files $uri $uri/ /index.html catches any URL path that doesn't match a physical file and serves index.html instead. This is how Angular's client-side router gets control of deep links like /dashboard/settings.
  • The /api/ location block proxies any API request to the backend service on port 8080. The service name backend will resolve via Docker's internal DNS when you use Docker Compose later.
  • The static asset block sets a one-year cache header on files like .js and .css. Angular's build hashes filenames on each build, so long cache times are safe.
  • Save frontend/nginx.conf.

You won't see a visible change yet. This configuration will be used when you build the Docker image next.

Now you need the Dockerfile that builds the Angular app and serves it with this Nginx config.

  • Create a file called Dockerfile inside your frontend/ directory by running:
touch frontend/Dockerfile
  • Open frontend/Dockerfile and paste the following multi-stage build:
# Stage 1: Build Angular app
FROM node:22-alpine AS builder
WORKDIR /app

# Copy package files first for dependency caching
COPY package.json package-lock.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN npx ng build --configuration=production

# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=builder /app/dist/*/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

What does this Dockerfile do?

This follows the same multi-stage pattern as the backend, but for a JavaScript app:

  • Stage 1 uses node:22-alpine to install dependencies with npm ci and run the production build. Copying package.json first means Docker caches the dependency install layer separately from your source code.
  • Stage 2 uses nginx:alpine (a tiny ~25 MB image) and copies only the compiled static files from Stage 1. The entire Node.js toolchain is discarded.
  • The output path dist/*/browser matches Angular 17+ build output structure, where the project name fills the wildcard.

Why multi-stage for the frontend too?

The Node.js build environment (hundreds of megabytes of dev dependencies) never ships to production. Your final image contains only Nginx and a handful of static files. This produces an image around 25 MB instead of 1+ GB.

  • Save frontend/Dockerfile.
  • Confirm both new files exist by running:
ls frontend/Dockerfile frontend/nginx.conf

You should see both files listed in the output.

Don't see both files?

  • Make sure you're running the ls command from your project root directory (the folder that contains backend/ and frontend/).
  • Check that you created the files inside frontend/ and not in the project root.

Need more help? Why can't I find my Dockerfile or nginx.conf?

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on to the build.

ⓧ I'd like to double check the full code

Here is the complete frontend/nginx.conf:

server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # Handle Angular client-side routing
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to backend
    location /api/ {
        proxy_pass http://backend:8080/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Cache static assets
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

And the complete frontend/Dockerfile:

# Stage 1: Build Angular app
FROM node:22-alpine AS builder
WORKDIR /app

# Copy package files first for dependency caching
COPY package.json package-lock.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN npx ng build --configuration=production

# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=builder /app/dist/*/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Build and test the frontend image

With both files in place, you can build the image and verify that Nginx serves the Angular app correctly.

  • Build the frontend Docker image by running this command from your project root:
docker build -t myapp-frontend ./frontend

What does this command do?

  • docker build runs the multi-stage build defined in frontend/Dockerfile.
  • -t myapp-frontend tags the resulting image with the name myapp-frontend.
  • ./frontend sets the build context to the frontend directory. Your frontend/.dockerignore keeps node_modules/ and dist/ out of the build context.

The build will take a minute or two on the first run while it downloads the Node.js base image and installs dependencies. You should see output ending with a successful image ID.

Build failed?

  • If you see an error about package-lock.json not found, make sure your Angular project has a lockfile. Run npm install in frontend/ to generate one.
  • If the ng build step fails, check that your Angular project compiles locally first by running npx ng build inside the frontend/ directory.
  • Make sure Docker Desktop is running.

Need more help? Why is my frontend Docker build failing?

  • Run the frontend container by executing:
docker run -p 80:80 myapp-frontend
  • Open your browser and navigate to http://localhost.

You should see your Angular application loading. The app renders because Nginx is serving the compiled static files from the container.

What about the API proxy?

API calls to /api/ won't work yet because there's no container named backend on the network. Docker Compose (next step) will wire both containers together so the proxy resolves correctly.

  • Stop the running container by pressing Ctrl+C in your terminal.

Nothing loads at localhost?

  • Check if port 80 is already in use by another process. Try docker run -p 8081:80 myapp-frontend and visit http://localhost:8081 instead.
  • Verify the image built successfully by running docker images and confirming myapp-frontend appears in the list.

Need more help? Why isn't my Angular container serving pages on localhost?

Both your backend and frontend are now containerized independently. Next, you'll wire them together with Docker Compose so they can communicate on the same network.

Docker Compose for Local Development

You have two working Docker images. The backend serves the API on port 8080 and the frontend serves the Angular app on port 80. Running them individually with separate docker run commands works, but it does not wire them together the way your nginx.conf expects.

Docker Compose solves this by defining both services in a single file. It creates a shared network where containers reach each other by service name, passes environment variables from your .env file, and starts everything with one command.

In this step, get ready to:
  • Create a docker-compose.yml that orchestrates both services with health checks and networking.
  • Verify both containers start and communicate using docker compose up --build.
  • Clean up with docker compose down.
Create the docker-compose.yml file

The Compose file lives at the root of your monorepo, alongside your .env and .gitignore files.

  • Create a new file called docker-compose.yml in your project root (the same folder that contains backend/ and frontend/).
  • Add the backend service definition by pasting this code into docker-compose.yml:
services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - DB_HOST=${DB_HOST:-localhost}
      - DB_PORT=${DB_PORT:-5432}
      - DB_NAME=${DB_NAME:-myapp}
      - DB_USERNAME=${DB_USERNAME:-postgres}
      - DB_PASSWORD=${DB_PASSWORD:-secret}
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

What does this code do?

The build section tells Compose to build the image from ./backend/Dockerfile instead of pulling a pre-built image.

The environment section uses the ${VAR:-default} syntax. Compose reads your .env file automatically. If a variable exists in .env, it uses that value. If not, it falls back to the default after :-.

The healthcheck uses wget to poll the Spring Boot health endpoint every 30 seconds. The start_period gives the JVM 40 seconds to boot before Compose starts counting failures.

  • Add the frontend service below the backend service in the same file:

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      backend:
        condition: service_healthy

What does this code do?

The depends_on with condition: service_healthy ensures the frontend container only starts after the backend passes its health check. This prevents the Nginx proxy from sending requests to a backend that is not ready yet.

Compose automatically creates a network where each service is reachable by its name. Your nginx.conf already references http://backend:8080 in its proxy pass. That hostname resolves because Compose registers the backend container under the DNS name backend.

  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - DB_HOST=${DB_HOST:-localhost}
      - DB_PORT=${DB_PORT:-5432}
      - DB_NAME=${DB_NAME:-myapp}
      - DB_USERNAME=${DB_USERNAME:-postgres}
      - DB_PASSWORD=${DB_PASSWORD:-secret}
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      backend:
        condition: service_healthy
Run Docker Compose and verify both services

With the Compose file saved, you can now start both services together and confirm they communicate.

  • Make sure any standalone containers from earlier testing are stopped. If you still have myapp-backend or myapp-frontend running from previous steps, stop them first with docker stop so ports 8080 and 80 are free.
  • Build and start both services by running this command from your project root:
docker compose up --build

What does this command do?

The --build flag forces Compose to rebuild both Docker images before starting. Without it, Compose reuses cached images even if your Dockerfiles or source code changed.

Compose streams logs from both containers to your terminal. You will see the backend JVM startup followed by the frontend Nginx startup once the health check passes.

  • Wait for the backend health check to pass. You will see log output from both services. The frontend container starts only after the backend reports as healthy (this takes around 40-60 seconds on first run due to the JVM start period).
  • Open your browser and navigate to http://localhost. You should see your Angular app loading.
  • Navigate to http://localhost/api/ to confirm the Nginx proxy is forwarding API requests to the backend container.

Services not starting?

If you see a port conflict error, a container from your earlier testing is still running. Run docker ps to find it, then stop it with docker stop <container-id>.

If the frontend never starts and the logs show the backend health check failing, check that your backend/Dockerfile installs wget in the runtime stage. Alpine images include wget by default, but a custom base image might not.

If you see docker compose is not recognised, make sure Docker Desktop is running. Docker Compose V2 ships as a docker subcommand with Docker Desktop.

help me debug Docker Compose startup failures

  • Once you have confirmed both services are working, return to your terminal and press Ctrl+C to stop the containers.
  • Clean up the stopped containers by running:
docker compose down
  • Verify the containers are gone by running docker ps -a. You should see no containers related to your project.

Why Docker Compose is for local dev only

Docker Compose orchestrates containers on a single machine. It does not handle multiple availability zones, rolling deployments, auto-scaling, or load balancing.

In production, ECS Fargate handles orchestration. You will set that up in a later step. Compose gives you a faithful local replica of your multi-container architecture without needing any cloud resources.

Your local development environment is now fully containerized. Both services start with a single command and communicate over a shared network. Next up, you will push these images to AWS ECR and store production secrets in Secrets Manager.

Set Up AWS ECR and Secrets Manager

Your Docker images are built and tested locally. Now you need a place to store them where AWS can pull them during deployment.

In this step, you'll create container registries in AWS ECR, store your application secrets in AWS Secrets Manager, and push your images to verify the pipeline works end-to-end before automating it.

In this step, get ready to:
  • Create two ECR repositories for the backend and frontend images.
  • Store application secrets in AWS Secrets Manager.
  • Push your Docker images to ECR manually to verify the setup.
Create the ECR repositories

ECR (Elastic Container Registry) is a Docker registry hosted by AWS. Each image needs its own repository, and you can enable automatic vulnerability scanning on every push.

  • Create the backend repository with vulnerability scanning enabled by running this command:
aws ecr create-repository \
    --repository-name myapp-backend \
    --image-scanning-configuration scanOnPush=true
  • Create the frontend repository with the same scanning configuration by running:
aws ecr create-repository \
    --repository-name myapp-frontend \
    --image-scanning-configuration scanOnPush=true

What do these commands do?

Each command creates a new container image repository in your configured AWS region (us-east-1). The --image-scanning-configuration scanOnPush=true flag tells ECR to automatically scan every pushed image for known security vulnerabilities.

You'll see JSON output containing a repositoryUri field for each repository. This URI follows the pattern ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/myapp-backend and is what you'll use to tag and push images.

  • Verify both repositories exist by running:
aws ecr describe-repositories --repository-names myapp-backend myapp-frontend

You should see JSON output listing both repositories with "imageScanningConfiguration": {"scanOnPush": true} in each entry.

Seeing an error about the repository already existing?

If you get a RepositoryAlreadyExistsException, you may have created it in a previous attempt. Run the describe-repositories command above to confirm it exists and move on.

If you see an access denied error, confirm your IAM user has AdministratorAccess attached and that your CLI is configured for us-east-1.

Still stuck? Help me troubleshoot my ECR repository creation error.

Create a secret in AWS Secrets Manager

In production, secrets like database passwords must never live in environment variables or .env files. They belong in a dedicated secrets store that encrypts them at rest and injects them at runtime.

  • Create your production secret by running this command:
aws secretsmanager create-secret \
    --name myapp/prod \
    --secret-string '{"DB_PASSWORD":"secret","API_KEY":"key123"}'

What does this command do?

This stores a JSON object with two key-value pairs (DB_PASSWORD and API_KEY) as an encrypted secret named myapp/prod. Secrets Manager encrypts the value using the AWS-managed KMS key.

Later, your ECS task definition will reference this secret's ARN in its secrets section, using valueFrom to inject values at container start. This keeps secrets out of environment variables, which can leak in logs and docker inspect output.

  • Note the ARN value in the command output. You will need it in the next step when configuring the ECS task definition.

Why Secrets Manager instead of environment variables?

The .env file works great for local development. In production, secrets passed as plain environment variables appear in docker inspect output, CloudWatch logs, and task metadata endpoints.

Secrets Manager encrypts values at rest and injects them directly into container memory at startup. This is the approach interviewers expect for production-grade deployments.

Getting a ResourceExistsException?

If the secret already exists from a previous attempt, you can update its value instead by running:

aws secretsmanager put-secret-value \
    --secret-id myapp/prod \
    --secret-string '{"DB_PASSWORD":"secret","API_KEY":"key123"}'

Need more help? Help me resolve my Secrets Manager error.

Authenticate Docker with ECR and push images

Before pushing images, Docker needs to authenticate with your ECR registry. This manual push confirms the full pipeline works before you automate it with GitHub Actions.

  • Authenticate Docker with 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

What does this command do?

The aws ecr get-login-password command generates a temporary authentication token. The pipe sends it to docker login which stores the credentials for your ECR registry. This token is valid for 12 hours.

You should see Login Succeeded in your terminal output.

  • Tag your backend image with the ECR registry URI by running:
docker tag myapp-backend:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/myapp-backend:latest
  • Push the backend image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/myapp-backend:latest
  • Tag and push the frontend image by running these commands:
docker tag myapp-frontend:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/myapp-frontend:latest
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/myapp-frontend:latest

What is happening during the push?

Docker pushes each image layer individually. Because you used multi-stage builds with proper layer caching, the base image layers (Alpine, JRE, Nginx) upload once and get reused across future pushes.

The :latest tag tells ECR this is the most recent version. In your CI/CD pipeline (next step), you'll also tag with the git commit SHA for traceability.

  • Verify both images appear in ECR by running:
aws ecr list-images --repository-name myapp-backend
aws ecr list-images --repository-name myapp-frontend

You should see JSON output containing an imageTag of latest in the imageIds array for each repository.

Push failed or timed out?

If you see an authorization error, your login token may have expired. Re-run the aws ecr get-login-password command above to generate a fresh token.

If the push times out, check your internet connection. Large base image layers can take a few minutes on slower connections.

Having a different issue? Help me troubleshoot my Docker push to ECR.

Your container registry and secrets store are ready. Next up, you'll automate the entire build-tag-push cycle with a GitHub Actions CI/CD pipeline.

Build the GitHub Actions CI/CD Pipeline

Your Docker images are built, tested, and pushed to ECR manually. That works for a one-off deployment, but it falls apart the moment your team pushes code multiple times a day.

In this step, you'll automate the entire build-push-deploy cycle with GitHub Actions. Every push to main will run your tests, build both Docker images, push them to ECR, and trigger an ECS deployment. No manual steps.

In this step, get ready to:
  • Create a GitHub Actions workflow file that tests, builds, and deploys on every push to main.
  • Add AWS credentials as GitHub repository secrets.
  • Trigger your first CI/CD run and verify images appear in ECR.
Create the workflow file with the test job

GitHub Actions workflows live in a special directory at the root of your repository. The file path itself tells GitHub to treat it as a workflow.

  • Create the .github/workflows/ directory structure inside your project root by running these commands:
mkdir -p .github/workflows
  • Create a new file called deploy.yml inside .github/workflows/ and add the following code:
name: Build and Deploy to ECS

on:
  push:
    branches:
      - main

env:
  AWS_REGION: us-east-1
  ECS_CLUSTER: myapp-cluster
  ECS_SERVICE: myapp-service
  BACKEND_ECR_REPOSITORY: myapp-backend
  FRONTEND_ECR_REPOSITORY: myapp-frontend

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Run backend tests
        working-directory: ./backend
        run: ./mvnw test

What does this code do?

The on: push: branches: - main block tells GitHub to run this workflow every time code is pushed to the main branch.

The env section defines workflow-level variables so you don't repeat values across jobs. The test job checks out your code, installs JDK 21, and runs your Maven test suite. If any test fails, the entire pipeline stops.

You won't see a change yet. The next chunk adds the build-and-deploy job that depends on this test job.

Add the build-and-deploy job

The build-and-deploy job only runs after the test job passes. It authenticates with AWS, logs into ECR, builds both Docker images, tags them with the git commit SHA for traceability, and triggers a new ECS deployment.

  • In .github/workflows/deploy.yml, add the following build-and-deploy job below the test job (at the same indentation level as test:):
  build-and-deploy:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push backend image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:$IMAGE_TAG ./backend
          docker tag $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:latest

      - name: Build and push frontend image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG ./frontend
          docker tag $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:latest

      - name: Force new ECS deployment
        run: |
          aws ecs update-service \
            --cluster $ECS_CLUSTER \
            --service $ECS_SERVICE \
            --force-new-deployment

What does this code do?

The needs: test line creates a dependency chain. This job only starts after the test job succeeds.

The aws-actions/configure-aws-credentials@v4 action sets up AWS access using your repository secrets. The aws-actions/amazon-ecr-login@v2 action authenticates Docker with your ECR registry.

Each image gets two tags: the git SHA (for traceability) and latest (for the ECS service to pull). The final step forces ECS to pull the new images and replace running tasks.

  • Save .github/workflows/deploy.yml.

Seeing YAML indentation errors?

YAML is whitespace-sensitive. The build-and-deploy: key must be at the same level as test: (two spaces in from jobs:). Each steps: item starts with a dash at six spaces of indentation.

help me fix my deploy.yml indentation

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

name: Build and Deploy to ECS

on:
  push:
    branches:
      - main

env:
  AWS_REGION: us-east-1
  ECS_CLUSTER: myapp-cluster
  ECS_SERVICE: myapp-service
  BACKEND_ECR_REPOSITORY: myapp-backend
  FRONTEND_ECR_REPOSITORY: myapp-frontend

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Run backend tests
        working-directory: ./backend
        run: ./mvnw test

  build-and-deploy:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push backend image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:$IMAGE_TAG ./backend
          docker tag $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$BACKEND_ECR_REPOSITORY:latest

      - name: Build and push frontend image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG ./frontend
          docker tag $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$FRONTEND_ECR_REPOSITORY:latest

      - name: Force new ECS deployment
        run: |
          aws ecs update-service \
            --cluster $ECS_CLUSTER \
            --service $ECS_SERVICE \
            --force-new-deployment
Add GitHub repository secrets and trigger the pipeline

The workflow references four secrets via ${{ secrets.* }} syntax. GitHub encrypts these values and injects them at runtime so your credentials never appear in code or logs.

  • Open your GitHub repository in a browser.
  • Click Settings in the top navigation bar.
  • In the left sidebar under the Security section, expand Secrets and variables, then click Actions.
  • Click the Secrets tab, then click New repository secret.
  • Add the first secret with Name AWS_ACCESS_KEY_ID and paste your IAM access key ID as the value.
  • Click Add secret.
  • Repeat for three more secrets: AWS_SECRET_ACCESS_KEY (your IAM secret key), AWS_REGION (enter us-east-1), and AWS_ACCOUNT_ID (your 12-digit AWS account number).

Why store credentials as GitHub secrets?

GitHub encrypts secrets at rest and only exposes them to workflow runs. They never appear in logs (GitHub redacts them automatically). This is the standard way to give CI/CD pipelines access to cloud resources without committing credentials to your repository.

  • Now push the workflow file to trigger your first CI/CD run by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD workflow"
git push origin main
  • In your GitHub repository, click the Actions tab to watch the workflow run.
  • Wait for both the test and build-and-deploy jobs to complete with green checkmarks.
  • Verify the new images in ECR by running:
aws ecr list-images --repository-name myapp-backend --region us-east-1

You should see two image tags: one matching your latest git commit SHA, and one tagged latest.

Workflow failed at the build-and-deploy step?

Check that all four repository secrets are named exactly as the workflow expects: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and AWS_ACCOUNT_ID. A typo in any secret name causes the credentials step to fail silently.

If the test job failed, check the workflow logs for Maven errors. Your backend tests must pass before the build job can start.

help me debug my GitHub Actions workflow failure

Understand the :latest tag trade-off

Why not use :latest in production?

This pipeline tags images with both the git SHA and latest. The latest tag is mutable. Every push overwrites it. That means you cannot tell which commit is currently deployed by looking at the tag alone.

In a production environment, you would use aws-actions/amazon-ecs-deploy-task-definition@v2 with the specific git SHA tag. This creates immutable deployments where every running task maps to an exact commit. Rollback becomes trivial because you just redeploy the previous SHA.

We use latest here combined with aws ecs update-service --force-new-deployment for simplicity. The ECS service pulls the newest latest image and replaces running tasks. For a learning project, this keeps the pipeline short. In an interview, explain the trade-off.

Your CI/CD pipeline is live. Every push to main now automatically tests, builds, and pushes your images. Next up, you'll deploy the application to ECS Fargate behind an Application Load Balancer.

Deploy to AWS ECS Fargate with ALB

Your CI/CD pipeline is pushing Docker images to ECR on every push to main. The last piece is getting those containers running in production with proper networking, health checks, and automatic rollback.

In this step, you will deploy your application to AWS ECS Fargate behind an Application Load Balancer. The ALB gives your app a stable DNS name, routes traffic only to healthy containers, and enables SSL termination later. The deployment circuit breaker ensures that if a bad image gets pushed, ECS automatically rolls back to the last working version.

In this step, get ready to:
  • Create an ECS cluster, ALB, and target group with optimized health checks.
  • Register a task definition with ECR images, secrets injection, and CloudWatch Logs.
  • Launch an ECS service with deployment circuit breaker and automatic rollback.
Create the ECS cluster, ALB, and target group

An ECS cluster is the logical grouping where your tasks run. The Application Load Balancer sits in front of your containers and routes traffic to healthy ones. A target group tells the ALB where to send requests and how to check container health.

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

What does this command do?

This creates an empty ECS cluster named myapp-cluster. The cluster itself does not cost anything. It is just a logical namespace where your services and tasks will run.

  • You should see a JSON response with "status": "ACTIVE" confirming the cluster was created.

Next, you need to create the ALB. An ALB requires subnets from at least two Availability Zones in your VPC.

  • Find your default VPC subnets by running:
aws ec2 describe-subnets \
    --filters "Name=default-for-az,Values=true" \
    --query "Subnets[*].SubnetId" \
    --output text
  • Note two subnet IDs from the output. You will use them in the next command.
  • Create the Application Load Balancer by running this command, replacing the subnet IDs with your own:
aws elbv2 create-load-balancer \
    --name myapp-alb \
    --subnets [[SUBNET1="your-first-subnet-id"]] [[SUBNET2="your-second-subnet-id"]] \
    --security-groups [[SG_ID="your-default-security-group-id"]]

What does this command do?

This creates an internet-facing Application Load Balancer named myapp-alb spanning two Availability Zones. The --type defaults to application so you do not need to specify it.

Note the LoadBalancerArn and DNSName from the output. You will need both later.

  • Get your VPC ID from the output above, then create the target group by running:
aws elbv2 create-target-group \
    --name myapp-tg \
    --protocol HTTP \
    --port 8080 \
    --target-type ip \
    --vpc-id [[VPC_ID="your-vpc-id"]] \
    --health-check-path /actuator/health \
    --health-check-interval-seconds 10 \
    --healthy-threshold-count 2 \
    --unhealthy-threshold-count 2

Why these health check settings?

The target type ip is required for Fargate's awsvpc network mode. Each Fargate task gets its own IP address, and the ALB routes directly to it.

The health check path /actuator/health hits your Spring Boot health endpoint. The interval of 10 seconds with thresholds of 2 means the ALB detects unhealthy containers in 20 seconds and marks them healthy again in 20 seconds. These are aggressive settings for faster feedback during development.

  • Note the TargetGroupArn from the output.
  • Create the ALB listener on port 80 that forwards traffic to your target group:
aws elbv2 create-listener \
    --load-balancer-arn [[ALB_ARN="your-load-balancer-arn"]] \
    --protocol HTTP \
    --port 80 \
    --default-actions Type=forward,TargetGroupArn=[[TG_ARN="your-target-group-arn"]]
  • You should see a JSON response confirming the listener was created on port 80.

Seeing an error about subnets or security groups?

Make sure you are using subnets from at least two different Availability Zones. If you get a security group error, use aws ec2 describe-security-groups to find your default VPC security group ID.

Also ensure the security group allows inbound traffic on port 80 (HTTP). You can add the rule with aws ec2 authorize-security-group-ingress.

help me fix my ALB creation error

Create the ECS task definition

A task definition is the blueprint for your containers. It specifies which images to run, how much CPU and memory to allocate, where to send logs, and how to inject secrets from AWS Secrets Manager.

  • First, create the IAM execution role that allows ECS to pull images from ECR and read secrets from Secrets Manager. Open the AWS Console and navigate to IAM in the search bar.
  • Click Roles in the left sidebar, then click Create role.
  • Select Custom trust policy and paste the following trust relationship:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
  • Click Next.
  • Search for and attach the AmazonECSTaskExecutionRolePolicy managed policy.
  • Also attach the SecretsManagerReadWrite managed policy for Secrets Manager access.
  • Name the role ecsTaskExecutionRole and click Create role.

What is the execution role vs task role?

The execution role is used by ECS itself to pull your container image from ECR and fetch secrets from Secrets Manager on behalf of your task. It runs before your app starts.

A task role is used by your application code at runtime to call other AWS services. For this project, the execution role is all you need.

  • Now create a file called task-definition.json in your project root with the following content:
{
  "family": "myapp",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "backend",
      "image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/myapp-backend:latest",
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "essential": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/myapp",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "backend"
        }
      },
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:myapp/prod:DB_PASSWORD::"
        },
        {
          "name": "API_KEY",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:myapp/prod:API_KEY::"
        }
      ]
    },
    {
      "name": "frontend",
      "image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/myapp-frontend:latest",
      "portMappings": [
        {
          "containerPort": 80,
          "protocol": "tcp"
        }
      ],
      "essential": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/myapp",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "frontend"
        }
      }
    }
  ]
}

What does this task definition configure?

  • awsvpc network mode gives each task its own ENI and private IP. This is required for Fargate.
  • 0.5 vCPU (512) and 1 GB memory (1024) is the minimum config suitable for a Spring Boot app.
  • The secrets section uses valueFrom to inject individual keys from your Secrets Manager secret directly into the container's environment at runtime. Secrets never appear in environment variable logs or docker inspect output.
  • The awslogs log driver sends container stdout/stderr to CloudWatch Logs so you can debug without SSHing into anything.
  • Replace all YOUR_ACCOUNT_ID placeholders with your AWS account ID. You can find it by running aws sts get-caller-identity.
  • Create the CloudWatch log group by running:
aws logs create-log-group --log-group-name /ecs/myapp
  • Register the task definition by running:
aws ecs register-task-definition --cli-input-json file://task-definition.json
  • You should see a JSON response showing "status": "ACTIVE" with your task definition details.

Task definition registration failing?

The most common cause is a malformed JSON file. Run cat task-definition.json | python3 -m json.tool to validate the JSON syntax.

Also verify your IAM role ARN is correct and the role exists. If the role was not created yet, the registration will still succeed, but the task will fail to start later.

help me debug my ECS task definition error

Create the ECS service with deployment circuit breaker

The ECS service ties everything together. It tells ECS to run your task definition on Fargate, attach it to the ALB target group, and automatically roll back if the new containers fail health checks.

  • Create the ECS service by running the following command, replacing the placeholder values:
aws ecs create-service \
    --cluster myapp-cluster \
    --service-name myapp-service \
    --task-definition myapp \
    --desired-count 1 \
    --launch-type FARGATE \
    --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
    --network-configuration "awsvpcConfiguration={subnets=[[[SUBNET1="your-first-subnet-id"]],[[SUBNET2="your-second-subnet-id"]]],securityGroups=[[[SG_ID="your-security-group-id"]]],assignPublicIp=ENABLED}" \
    --load-balancers "targetGroupArn=[[TG_ARN="your-target-group-arn"]],containerName=backend,containerPort=8080"

What does this command configure?

  • deploymentCircuitBreaker={enable=true,rollback=true} tells ECS to monitor new deployments. If tasks repeatedly fail health checks, ECS automatically rolls back to the last successful deployment.
  • assignPublicIp=ENABLED is needed so Fargate tasks can pull images from ECR over the internet. In production, you would use a NAT gateway instead.
  • The load-balancers parameter attaches your backend container (port 8080) to the target group. The ALB routes incoming port-80 traffic to this container.
  • Wait a few minutes for the task to start. Check the service status by running:
aws ecs describe-services \
    --cluster myapp-cluster \
    --services myapp-service \
    --query "services[0].deployments"
  • You should see "runningCount": 1 and "rolloutState": "IN_PROGRESS" or "COMPLETED".
  • Access your app by getting the ALB DNS name:
aws elbv2 describe-load-balancers \
    --names myapp-alb \
    --query "LoadBalancers[0].DNSName" \
    --output text
  • Open the DNS name in your browser. You should see your application responding.

Why never expose a Fargate task's public IP directly?

Fargate tasks get ephemeral public IPs that change every time a task restarts. The ALB provides a stable DNS name, health-check-based routing (only healthy containers get traffic), SSL termination (with ACM), and high availability across Availability Zones.

In an interview, you can explain that the ALB is the single entry point to your service. It handles all the networking complexity so your containers can focus on application logic.

Task stuck in PENDING or STOPPED?

Check the stopped task reason with aws ecs describe-tasks. Common causes include: the security group not allowing outbound internet access (needed to pull ECR images), the execution role missing ECR or Secrets Manager permissions, or the CloudWatch log group not existing.

If the task starts but the target shows unhealthy in the ALB, verify your Spring Boot app exposes /actuator/health on port 8080. The health check must return HTTP 200.

help me debug why my ECS Fargate task is failing

Your production deployment is live. Push code to main, and your GitHub Actions pipeline will automatically test, build Docker images, push to ECR, and force a new ECS deployment. Next up, you will add blue/green deployments for zero-downtime releases.

Secret mission

Add Blue/Green Deployment with Test Listener

Your current pipeline deploys with a circuit breaker that rolls back on failure. But what if you could validate a new version before it ever receives production traffic? In this secret mission, you'll add a blue/green deployment strategy with a test listener that lets you preview new deployments on a separate port before shifting traffic.

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 AWS resources covered by your free credits, but they do incur charges over time if left running.

Cost warning

Your ECS Fargate tasks and Application Load Balancer charge by the hour. At minimum configuration (0.5 vCPU + 1 GB memory), expect roughly $2-3/day if left running. Your $100-200 in AWS free credits cover this, but they expire after 6 months.

If you are not actively using this deployment, choose Pause or Delete below to stop charges immediately.

Resources you used:

  • ECS Fargate service (myapp-service in myapp-cluster).
  • Application Load Balancer (myapp-alb) with blue and green target groups.
  • ECR repositories (myapp-backend and myapp-frontend).
  • Secrets Manager secret (myapp/prod).
  • IAM roles (task role and execution role).
  • GitHub Actions repository secrets.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or testing deployments.

  • Your ECS service continues to run and serve traffic through the ALB.
  • Pushing to main still triggers your CI/CD pipeline.
  • Approximate cost: $2-3/day at minimum configuration, covered by your AWS free credits.

Pause - I'll come back to this later

Shut down running tasks to stop compute charges while keeping your configuration intact.

  • Scale the ECS service to zero tasks by running:
aws ecs update-service --cluster myapp-cluster --service myapp-service --desired-count 0
  • Delete the Application Load Balancer to stop the ALB hourly charge. In the AWS Console, open the EC2 service.
  • Select Load Balancers in the left sidebar.
  • Select myapp-alb, click Actions, then click Delete load balancer.

Your ECS cluster, task definition, ECR images, and Secrets Manager secret remain intact for when you return.

Delete - I don't want to use this again

Remove all AWS resources and local files created during this project. Run the following commands in order.

  • Scale the ECS service to zero and delete it by running:
aws ecs update-service --cluster myapp-cluster --service myapp-service --desired-count 0
aws ecs delete-service --cluster myapp-cluster --service myapp-service --force
  • Delete the ECS cluster by running:
aws ecs delete-cluster --cluster myapp-cluster
  • Delete the Application Load Balancer. In the AWS Console, open the EC2 service.
  • Select Load Balancers in the left sidebar.
  • Select myapp-alb, click Actions, then click Delete load balancer. Confirm by typing confirm.
  • Delete both target groups. In the same EC2 console, select Target Groups in the left sidebar.
  • Select each target group (blue and green), click Actions, then click Delete.
  • Delete the ECR repositories by running:
aws ecr delete-repository --repository-name myapp-backend --force
aws ecr delete-repository --repository-name myapp-frontend --force
  • Delete the Secrets Manager secret by running:
aws secretsmanager delete-secret --secret-id myapp/prod --force-delete-without-recovery
  • Delete the IAM roles. In the AWS Console, open the IAM service.
  • Select Roles in the left sidebar.
  • Search for the task role and execution role you created, select each one, and click Delete.
  • Remove GitHub repository secrets. In your GitHub repository, go to Settings.
  • Select Secrets and variables, then Actions.
  • Delete each secret: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and AWS_ACCOUNT_ID.
  • Delete your local project files:

macOS

cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

Windows

cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade deployment pipeline for a Java + Angular application, and you can explain every layer of it in a senior dev interview.

You've learned how to:

  • Build multi-stage Docker images optimized for Spring Boot (layered JARs) and Angular (Nginx), with a GitHub Actions CI/CD pipeline that automatically tests, builds, and pushes to AWS ECR on every push to main.
  • Deploy containers to AWS ECS Fargate behind an Application Load Balancer with health-check routing, secrets injected from AWS Secrets Manager via the task definition (not environment variables), and deployment circuit breakers for automatic rollback.
  • Configure a monorepo like a senior dev with proper .gitignore, externalized Spring Boot profiles, .env files for local development, and Docker Compose for local multi-container orchestration.
  • Secret Mission: Implemented blue/green deployments with a test listener for pre-production validation and zero-downtime releases.

Ready to quiz yourself?