CI/CD Pipeline with GitHub Actions
Build a pipeline that tests, containerizes, and publishes a Java app automatically.
Introduction
30 Second Summary
Every time a developer pushes code, someone has to test it, package it, and ship it. In professional teams, this happens automatically through a pipeline that catches bugs before they reach users.
In this project, you will build a CI/CD pipeline using GitHub Actions and Docker that automatically tests, containerizes, and publishes a Java application every time you push code.
What You'll Build
A complete CI/CD pipeline for a Java Spring Boot application that builds, tests, and pushes a production-optimized Docker image to GitHub Container Registry on every push to your main branch.
By the end of this project, you'll have:
- A GitHub Actions workflow that automatically runs tests, builds a Docker image, and pushes it to a container registry on every commit.
- A multi-stage Dockerfile that produces a minimal production image from a Java application.
- A container image tagged with the Git commit SHA, published to ghcr.io for traceability and deployment.
- Secret Mission: Add a Trivy container security scan to your pipeline that fails the build if critical vulnerabilities are found.
Are there any prerequisites?
You should have basic Java knowledge and be comfortable with Git fundamentals like cloning, committing, and pushing. You will also need a GitHub account and Docker Desktop installed on your machine.
Set Up Your Local Environment
Every tool in your CI/CD pipeline depends on three local foundations: a Java compiler to build your app, Docker to package it into a container, and Git to version and push your code.
In this step, you'll confirm each tool is installed and properly configured. If anything is missing, you'll get it set up before moving on.
In this step, get ready to:
- Verify Java 21 is installed for compiling your Spring Boot application.
- Confirm Docker Desktop is running for building container images.
- Check that Git is configured with your GitHub identity.
Install Java 21 (Eclipse Temurin)
Your Spring Boot application requires Java 21 to compile and run. Eclipse Temurin is a free, production-ready distribution of the Java Development Kit (JDK).
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check if Java 21 is already installed by running this command:
java -version
You should see output that includes a version number starting with 21.
✔️ I see version 21 or higher
Java 21 is ready. You can move on to verifying Docker Desktop.
ⓧ I see an older version
You have Java installed, but you need version 21 specifically. An older version will cause compatibility issues with Spring Boot 3.3.13.
macOS
macOS may resolve to an older Java version by default even after you install a newer one. Install Java 21 alongside your existing version using Homebrew.
- Install Eclipse Temurin 21 by running this command:
brew install --cask temurin@21
- Close and reopen your Terminal so the new Java is on your PATH.
- Verify the version by running:
java -version
If the output still shows an older version, you may need to set your JAVA_HOME explicitly. Run /usr/libexec/java_home -V to list all installed JDKs, then export the Java 21 path in your shell profile.
Windows
You need to install Java 21 alongside or replace your current version.
- Go to adoptium.net/temurin/releases.
- Select JDK 21 - LTS and download the Windows .msi installer.
- Run the installer. On the Custom Setup screen, make sure Set JAVA_HOME variable and Add to PATH are both selected.
- Close and reopen your terminal, then verify with:
java -version
ⓧ Command not found
Java is not installed on your machine. Follow the instructions below to install Eclipse Temurin 21.
macOS
- Install Eclipse Temurin 21 using Homebrew by running this command:
brew install --cask temurin@21
What if I don't have Homebrew?
If the brew command is not found, install Homebrew first by visiting brew.sh and following the one-line install instruction.
- Close and reopen your Terminal, then verify the installation:
java -version
You should now see a version number starting with 21.
Windows
- Go to adoptium.net/temurin/releases.
- Select JDK 21 - LTS and download the Windows .msi installer.
- Run the installer. On the Custom Setup screen, make sure Set JAVA_HOME variable and Add to PATH are both selected.
- Click Install to begin the installation.
- Close and reopen your terminal, then verify:
java -version
You should now see a version number starting with 21.
Still not seeing version 21?
If java -version still shows the wrong version after installing, your system PATH may be pointing to an older JDK. On macOS, run /usr/libexec/java_home -V to list installed JDKs. On Windows, check System Properties > Environment Variables and ensure the Temurin 21 path comes first in your PATH variable.
Help me fix my Java version so java -version shows 21.
Verify Docker Desktop is running
Docker Desktop provides the Docker engine that builds and runs container images on your machine. The engine must be running before you can use any docker commands.
- Check that Docker is installed by running this command:
docker --version
- Confirm the Docker engine is actually running by running:
docker info
If both commands succeed, you should see a version number from the first and a wall of system information from the second.
✔️ I see version info and system details
Docker Desktop is running and ready. Move on to verifying Git.
ⓧ I see an error or "Cannot connect to the Docker daemon"
This means Docker Desktop is installed but the engine is not running.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Docker Desktop and press Enter to launch it.
- Wait for the Docker icon in your menu bar or system tray to show a steady state (not animating).
- Run docker info again in your terminal. You should now see system details.
What if Docker Desktop is not installed?
Download Docker Desktop from docker.com/products/docker-desktop. Run the installer and follow the prompts. After installation, launch Docker Desktop and wait for the engine to start before continuing.
Docker commands still failing?
Make sure Docker Desktop has finished starting. The whale icon in your menu bar or system tray should be steady, not animating. If you just installed it, you may need to close and reopen your terminal.
Help me get Docker Desktop running on my machine.
Verify Git is configured with your GitHub credentials
When you push code to GitHub, Git attaches your name and email to every commit. These must match your GitHub account so the pipeline can link commits to you.
- Check your current Git configuration by running these commands:
git config --global user.name
git config --global user.email
Each command should print the name and email address associated with your GitHub account.
✔️ I see my GitHub name and email
Git is configured correctly. You're ready for the next step.
ⓧ Nothing appeared or the values are wrong
You need to set your Git identity to match your GitHub account.
- Set your name by running this command (replace the placeholder with your GitHub username):
git config --global user.name "[[GITHUB_USERNAME="your GitHub username"]]"
- Set your email by running this command (replace the placeholder with the email on your GitHub account):
git config --global user.email "[[GITHUB_EMAIL="your GitHub email address"]]"
- Confirm the values were saved by running:
git config --global user.name
git config --global user.email
You should now see your name and email printed back.
Not sure which email to use?
Use the email address associated with your GitHub account. You can check this in your GitHub profile settings under Emails. If you use GitHub's private email feature, use the noreply address GitHub provides.
Help me find the right email for my Git config.
Your local environment is locked and loaded. Next up, you'll create the Spring Boot application that your pipeline will build and deploy.
Create the Spring Boot Application
Your development environment is ready. Now you need an application for your pipeline to build, test, and deploy.
In this step, you will generate a Spring Boot project, add a REST endpoint, and write a unit test. These three pieces give your pipeline something meaningful to work with on every push.
In this step, get ready to:
- Generate a Spring Boot project from Spring Initializr.
- Create a REST controller that returns JSON.
- Write and run a unit test to verify the endpoint.
Generate the project with Spring Initializr
Spring Initializr is a web tool that scaffolds a ready-to-run Spring Boot project with the exact dependencies you need.
- Open your browser and navigate to start.spring.io.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select 3.3.13.
- In the Project Metadata section, set Group to com.example.
- Set Artifact to hello-cicd.
- Set Name to hello-cicd.
- Set Description to CI/CD Demo with GitHub Actions and Docker.
- Set Packaging to Jar.
- Set Java to 21.
- Click ADD DEPENDENCIES, type web in the search box, then select Spring Web.
- Click GENERATE to download the project as a zip file.
What is Spring Initializr?
Spring Initializr generates a complete Maven or Gradle project pre-configured with your chosen Spring Boot version and dependencies. It saves you from writing boilerplate build configuration by hand.
The Spring Web dependency adds spring-boot-starter-web to your project, which includes an embedded Tomcat server and the Spring MVC framework for building REST APIs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Unzip the downloaded file and move into the project folder by running these commands:
unzip ~/Downloads/hello-cicd.zip -d .
cd hello-cicd
- Confirm the project files exist by running:
ls
You should see pom.xml, mvnw, src/, and the .mvn/ directory in the output.
Don't see those files?
Make sure you unzipped the file into your Desktop folder. If the zip file has a different name, check your Downloads folder for the most recent .zip file and adjust the command accordingly.
help me find and extract my Spring Initializr download
- Open the project in your code editor. In VS Code, run:
code .
Create the REST controller
Your generated project has the main application class but no endpoints yet. You will add a controller that responds to HTTP GET requests with a JSON payload.
- Create a new file at src/main/java/com/example/hellocicd/HelloController.java and paste the following code:
package com.example.hellocicd;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Map;
// REST controller that handles HTTP requests
@RestController
public class HelloController {
// Responds to GET /api/hello with a JSON map
@GetMapping("/api/hello")
public Map<String, String> hello() {
return Map.of(
"message", "Hello from the CI/CD pipeline!",
"timestamp", Instant.now().toString()
);
}
}
What does this code do?
`@RestController` tells Spring this class handles HTTP requests and returns data directly (not an HTML template).
`@GetMapping("/api/hello")` maps GET requests at the /api/hello path to the hello() method. The method returns a Map that Spring automatically serializes to JSON.
- Save HelloController.java.
- Verify the controller works by starting the application:
./mvnw spring-boot:run
What should I see?
After Maven downloads dependencies (this takes a minute on first run), you will see a line like Started HelloCicdApplication in your terminal. The application is now listening on port 8080.
- Open a new browser tab and navigate to http://localhost:8080/api/hello.
You should see a JSON response with a message field saying Hello from the CI/CD pipeline! and a timestamp field.
- Return to your terminal and press Ctrl+C to stop the application.
Seeing a connection refused error?
Make sure the application fully started before visiting the URL. Look for the Started HelloCicdApplication line in the terminal output.
If port 8080 is already in use, you may see an address-already-in-use error. Close any other application using that port and try again.
help me troubleshoot my Spring Boot app not starting
✔️ Awesome, I've got everything!
Great. Double check you saved HelloController.java and confirmed the JSON response in your browser.
ⓧ I'd like to double check the full code
Here is the complete HelloController.java file:
package com.example.hellocicd;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Map;
@RestController
public class HelloController {
@GetMapping("/api/hello")
public Map<String, String> hello() {
return Map.of(
"message", "Hello from the CI/CD pipeline!",
"timestamp", Instant.now().toString()
);
}
}
Write and run the unit test
A CI/CD pipeline runs your tests on every push. If a test fails, the pipeline stops before building the image. This protects you from deploying broken code.
- Create a new file at src/test/java/com/example/hellocicd/HelloControllerTest.java and paste the following code:
package com.example.hellocicd;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
// Loads only the web layer for fast, focused testing
@WebMvcTest(HelloController.class)
class HelloControllerTest {
// Spring injects a mock HTTP client
@Autowired
private MockMvc mockMvc;
// Verifies the endpoint returns 200 OK with the expected message
@Test
void shouldReturnHelloMessage() throws Exception {
mockMvc.perform(get("/api/hello"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Hello from the CI/CD pipeline!"));
}
}
What does this test do?
`@WebMvcTest(HelloController.class)` starts a lightweight Spring context that only loads the web layer. This makes the test fast because it skips database and service initialization.
`MockMvc` simulates an HTTP GET request to /api/hello without starting a real server. The test asserts the response status is 200 and the JSON message field matches the expected string.
- Save HelloControllerTest.java.
- Run the test suite by executing this command in your terminal:
./mvnw test
You should see BUILD SUCCESS at the end of the output, confirming your test passed.
Seeing BUILD FAILURE?
Check that the message string in your test matches the one in HelloController.java exactly, including the exclamation mark.
Make sure both files are in the correct packages: the controller in src/main/java/com/example/hellocicd/ and the test in src/test/java/com/example/hellocicd/.
help me fix my failing Spring Boot test
✔️ Awesome, I've got everything!
Great. Double check you saved HelloControllerTest.java and saw BUILD SUCCESS in the terminal.
ⓧ I'd like to double check the full code
Here is the complete HelloControllerTest.java file:
package com.example.hellocicd;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@WebMvcTest(HelloController.class)
class HelloControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnHelloMessage() throws Exception {
mockMvc.perform(get("/api/hello"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Hello from the CI/CD pipeline!"));
}
}
Your application is built and tested. Next up, you will containerize it with a multi-stage Dockerfile so the pipeline can produce a production-ready image.
Write the Multi-Stage Dockerfile
Your Spring Boot app has a working endpoint and a passing test. The next challenge is packaging it into a container image that's ready for production.
Docker lets you define exactly what goes into your container. In this step, you'll write a multi-stage Dockerfile that compiles your app in one stage and runs it in a much smaller stage.
In this step, get ready to:
- Create a multi-stage Dockerfile for your Spring Boot application.
- Build and test the Docker image locally.
- Compare image sizes between the build and runtime stages.
Create the multi-stage Dockerfile
A standard Docker image for Java includes the entire JDK, which is hundreds of megabytes of tools your app never uses at runtime. A multi-stage build solves this by using a full JDK image to compile the JAR, then copying only the JAR into a minimal JRE image.
- Create a file called .dockerignore in the root of your hello-cicd project folder, and paste the following content:
target/
.git/
.github/
*.md
.dockerignore
What does this file do?
The .dockerignore file tells Docker which files to exclude from the build context. Without it, Docker sends your entire project directory (including the target/ folder, .git/ history, and markdown files) to the daemon, slowing down the build.
- Save .dockerignore.
- Create a file called Dockerfile (no extension) in the root of your hello-cicd project folder, and paste the following content:
# Stage 1: Build the application
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
# Copy Maven wrapper and pom.xml first for dependency caching
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -DskipTests
# Copy source code and build
COPY src/ src/
RUN ./mvnw package -DskipTests
# Stage 2: Create the runtime image
FROM eclipse-temurin:21-jre-jammy AS final
WORKDIR /app
# Create a non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser:appgroup
# Copy the built JAR from the builder stage
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
This Dockerfile has two stages:
- Stage 1 (builder): Uses the full JDK image (eclipse-temurin:21-jdk-jammy) to download dependencies and compile your Spring Boot app into a JAR file.
- Stage 2 (final): Uses only the JRE image (eclipse-temurin:21-jre-jammy), creates a non-root user for security, and copies just the compiled JAR from stage 1.
The dependency caching trick (copying pom.xml before source code) means Docker only re-downloads dependencies when pom.xml changes. Source code changes rebuild faster.
- Save Dockerfile.
File not saving without an extension?
Some editors try to add a .txt extension. Make sure the file is named exactly Dockerfile with a capital D and no file extension. In VS Code, use File > New File and type the name Dockerfile directly.
Help me fix my Dockerfile naming issue.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files in the root of your hello-cicd folder.
ⓧ I'd like to double check the full code
Here is the full Dockerfile:
# Stage 1: Build the application
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
# Copy Maven wrapper and pom.xml first for dependency caching
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -DskipTests
# Copy source code and build
COPY src/ src/
RUN ./mvnw package -DskipTests
# Stage 2: Create the runtime image
FROM eclipse-temurin:21-jre-jammy AS final
WORKDIR /app
# Create a non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser:appgroup
# Copy the built JAR from the builder stage
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
And here is the full .dockerignore:
target/
.git/
.github/
*.md
.dockerignore
Build and test the image locally
- Build your Docker image by running this command in your terminal (make sure you're in the hello-cicd folder):
docker build -t hello-cicd .
What does this command do?
The -t hello-cicd flag names (tags) your image. The trailing . tells Docker to use the current directory as the build context.
The first build takes a few minutes because Docker downloads the base images and all Maven dependencies. You'll see progress messages for each layer being built.
- Wait for the build to finish. You should see FINISHED in the output when it completes.
Build failing with permission denied?
Make sure Docker Desktop is running. On macOS and Windows, Docker Desktop starts the daemon that processes builds. If it's not running, you'll see a connection error.
If you see a permission error on ./mvnw, run chmod +x mvnw in your project folder to make the Maven wrapper executable.
Help me fix my Docker build error.
- Run your newly built image as a container by running:
docker run -p 8080:8080 hello-cicd
What does this command do?
The -p 8080:8080 flag maps port 8080 on your machine to port 8080 inside the container. Your Spring Boot app starts up and listens on that port, just like it does when you run it with Maven directly.
- Open your browser and navigate to http://localhost:8080/api/hello.
- You should see the JSON response with a message and timestamp field, just like before.
Not seeing a response in the browser?
Wait a few seconds after running the command. Spring Boot takes a moment to start up. Look for the Started HelloCicdApplication message in your terminal output.
If port 8080 is already in use (from a previous run), stop the other process first or use a different host port like -p 9090:8080.
Help me troubleshoot my Docker container not responding.
Stop the container and compare image sizes
- Return to the terminal where the container is running.
- Press Ctrl+C to stop the container.
- Check the sizes of your Docker images by running:
docker images
You should see your hello-cicd image in the list along with the base images Docker downloaded during the build.
Why is the runtime image so much smaller?
The JDK image (eclipse-temurin:21-jdk-jammy) includes the full Java compiler, development tools, and source headers. The JRE image (eclipse-temurin:21-jre-jammy) contains only what's needed to run compiled Java applications.
Your final hello-cicd image is based on the JRE stage. It's significantly smaller than a single-stage build would be, which means faster pulls, less storage, and a smaller attack surface in production.
Your app is containerized and running in a production-optimized image. Next up, you'll push your project to GitHub so you can automate this build process with a CI/CD pipeline.
Create the GitHub Repository and Push
Your application builds and runs inside a Docker container. That's great for local development, but a CI/CD pipeline needs access to your code from the cloud.
In this step, you'll push your project to GitHub so that GitHub Actions can detect changes and trigger the pipeline automatically.
In this step, get ready to:
- Create a public GitHub repository.
- Push your local project to the repository.
Create a new public repository on GitHub
GitHub Actions is free and unlimited for public repositories using standard runners. Making this repo public means your pipeline will run with no minute limits.
- Open github.com/new in your browser.
- Enter hello-cicd in the Repository name field.
- Select Public for repository visibility.
- Leave Add a README file unchecked. You already have code to push, so you do not want GitHub to initialize the repository with any files.
- Click Create repository.
Why public instead of private?
GitHub Actions usage is free for public repositories that use standard GitHub-hosted runners. A private repository on the GitHub Free plan gets 2,000 minutes per month, which is plenty for learning, but public keeps things simple with no limits.
After creation, GitHub shows a Quick Setup page with instructions. You'll use the commands below instead.
Initialize Git and push your code
Your project folder is not a Git repository yet. You need to initialize it, commit all files, connect it to the remote, and push.
- Initialize a new Git repository and stage all files by running these commands:
git init
git add .
What do these commands do?
The git init command creates a hidden .git folder that turns your project directory into a Git repository.
The git add . command stages every file in the current directory. Staged files are the ones that will be included in your next commit.
- Create your first commit by running:
git commit -m "Initial commit"
- You should see a summary listing all the files that were committed.
- Connect your local repository to GitHub by adding the remote origin. Replace the URL with your own repository URL:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR-USERNAME/hello-cicd.git"]]
Find your repository URL
On the Quick Setup page that appeared after you created the repository, copy the HTTPS URL. It follows the pattern https://github.com/<your-username>/hello-cicd.git.
- Push your code to the main branch on GitHub by running:
git push -u origin main
What does this command do?
The -u flag sets origin main as the default upstream branch. Future pushes only need git push without any extra arguments.
- Refresh your repository page on GitHub. You should see all your project files listed: pom.xml, Dockerfile, src/, and .dockerignore.
Push failed or asked for credentials?
If Git asks for a username and password, your GitHub credentials are not cached. GitHub no longer accepts account passwords for Git operations. You need a personal access token or SSH key configured.
If you see an error about the remote already existing, you may have run git remote add origin twice. Run git remote -v to check, then git remote set-url origin <correct-url> to fix it.
If the push is rejected because the remote contains work you do not have locally (a README was auto-created), delete the repository on GitHub and re-create it without initializing a README.
Still stuck? Help me fix my git push to GitHub.
Your code now lives on GitHub. Next up, you'll write the GitHub Actions workflow that turns every push into an automated build, test, and publish pipeline.
Build the GitHub Actions CI/CD Workflow
Your code is on GitHub, your Dockerfile is ready, and your tests pass locally. Now it is time to automate the entire build-test-push cycle so it happens on every single push.
In this step, you will create a GitHub Actions workflow file that triggers automatically whenever you push to main. The workflow runs your tests, builds your Docker image, and pushes it to GitHub Container Registry without you lifting a finger.
In this step, get ready to:
- Create the workflow file with a push trigger and job permissions.
- Add pipeline steps that test, build, and push your Docker image.
- Push the workflow and watch it run in the Actions tab on GitHub.
Create the workflow file with trigger and job configuration
GitHub Actions workflows live inside a special directory in your repository. GitHub automatically detects any .yml file inside .github/workflows/ and runs it according to the triggers you define.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Create and open the workflow file .github/workflows/ci.yml in your code editor.
- Paste the following code into ci.yml:
name: CI/CD Pipeline
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
What does this code do?
- name: CI/CD Pipeline gives your workflow a human-readable name that shows up in the Actions tab.
- on: push: branches: - main tells GitHub to run this workflow every time code is pushed to the main branch.
- runs-on: ubuntu-latest spins up a fresh Ubuntu virtual machine for your job. GitHub provides this runner for free on public repositories.
- permissions scopes the GITHUB_TOKEN to only read repository contents and write packages. This is the minimum access needed to push a container image.
You will not see any change yet. The next chunk adds the pipeline steps that do the real work.
Add the pipeline steps
Each step in a GitHub Actions workflow is a discrete task. You will chain together six steps: check out the code, set up Java, run tests, log in to the container registry, prepare the Docker builder, and build-and-push the image.
- In ci.yml, add the following steps block directly below the packages: write line. Make sure steps: is indented 4 spaces (aligned with permissions):
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run tests
run: ./mvnw test
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
What does each step do?
- Checkout code clones your repository onto the runner so subsequent steps can access your files.
- Set up Java 21 installs the Temurin JDK and caches your Maven dependencies. Caching means future runs skip re-downloading all packages.
- Run tests executes ./mvnw test. If any test fails, the workflow stops here and the image never gets built.
- Log in to GitHub Container Registry authenticates with ghcr.io using the built-in GITHUB_TOKEN secret. No extra secrets to configure.
- Set up Docker Buildx enables advanced Docker build features like layer caching.
- Build and push Docker image builds your multi-stage Dockerfile and pushes the resulting image to ghcr.io. It tags the image with both the commit SHA (for traceability) and latest.
Why tag with the commit SHA?
Every image tagged with a commit SHA gives you an exact link between what is running in production and what code produced it. If something breaks, you can look at the image tag and immediately find the exact commit responsible.
- Save .github/workflows/ci.yml.
Seeing YAML indentation errors?
YAML is strict about indentation. Make sure steps: is indented exactly 4 spaces from the left margin (aligned with permissions: and runs-on:). Each - name: under steps starts with 6 spaces.
If your editor uses tabs instead of spaces, check your editor settings. YAML does not allow tabs. Help me fix YAML indentation in my ci.yml workflow file
✔️ Awesome, I've got everything!
Great. Double check that you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/ci.yml file:
name: CI/CD Pipeline
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run tests
run: ./mvnw test
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Push the workflow and watch it run
The workflow triggers on push to main. The moment you push this new file, GitHub picks it up and starts running your pipeline automatically.
- Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI/CD workflow"
git push origin main
- Open your browser and navigate to your hello-cicd repository on GitHub.
- Under your repository name, click Actions.
- In the left sidebar, click CI/CD Pipeline.
- Click the workflow run (it will be named after your commit message, Add CI/CD workflow).
- Watch the job progress. A yellow dot means it is still running. A green checkmark means it passed.
How long does the pipeline take?
The first run takes 2-4 minutes because it downloads all Maven dependencies and Docker base images from scratch. Future runs will be faster thanks to the Maven and Docker layer caching you configured.
- Once the run shows a green checkmark, verify the published image by navigating to the main page of your repository.
- In the right sidebar of your repository, click Packages.
- You should see your hello-cicd container image listed with the latest tag and a tag matching your commit SHA.
Pipeline failed with a red X?
Click into the failed job to see which step errored. Common causes:
- Test failures: if the Run tests step failed, your unit test is not passing. Run ./mvnw test locally to reproduce.
- Permission denied on GHCR: make sure your repository is public. Private repos need extra token configuration for packages.
- YAML syntax error: check for tab characters or incorrect indentation. Use the double-check tab above to compare your file.
Help me debug my failed GitHub Actions pipeline run
Secret mission
Add Container Security Scanning with Trivy
Your pipeline builds and pushes images automatically. But what if one of those images contains a known vulnerability? Add a Trivy security scan that catches critical issues before they reach production.
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 is completely free. GitHub Actions is free for public repositories, and GitHub Container Registry storage is currently free.
Resources you used:
- Local hello-cicd project folder on your Desktop.
- Local Docker images built during development (hello-cicd).
- GitHub repository (hello-cicd).
- Container images on GitHub Container Registry (ghcr.io).
- Java 21 (Eclipse Temurin) installed via Homebrew.
Keep everything running
No action needed. Choose this if you plan to keep iterating on your pipeline or want to reference this project later. GitHub Actions only runs when you push code, so there are no ongoing costs.
- Your GitHub repository stays as-is. The pipeline triggers only on new pushes.
- Your container images remain on GHCR at no cost.
- Java 21 and Docker Desktop stay installed on your machine for future projects.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your code, images, and repository stay intact for when you return.
- Stop any running Docker containers by running:
- Quit Docker Desktop to free up system memory.
docker stop $(docker ps -q)
Your GitHub repository, container images, and local files all remain untouched until you come back.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Remove local Docker images
- Remove the Docker images you built locally by running:
docker rmi hello-cicd
docker image prune -f
Delete the local project folder
- Remove the project folder from your Desktop by running:
macOS
rm -rf ~/Desktop/hello-cicd
Windows
Remove-Item -Recurse -Force ~\Desktop\hello-cicd
Delete the container package on GHCR
- On GitHub, navigate to the main page of your hello-cicd repository.
- In the right sidebar, click Packages.
- Click the hello-cicd package name.
- On the package landing page, click Package settings on the right-hand side.
- Scroll to the bottom of the page. Under Danger Zone, click Delete this package.
- Type the package name to confirm, then click I understand, delete this package.
Delete the GitHub repository
- On GitHub, navigate to the main page of your hello-cicd repository.
- Under the repository name, click Settings.
- On the General settings page, scroll down to the Danger Zone section.
- Click Delete this repository.
- Click I want to delete this repository.
- Read the warnings and click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
Uninstall Java 21
- Uninstall the Eclipse Temurin JDK by running:
brew uninstall --cask temurin@21
Nice Work!
Nice Work!
You just built a production-grade CI/CD pipeline from scratch. Every time you push code to your repository, your pipeline automatically runs tests, builds a container image, and publishes it to a registry. This is the exact workflow DevOps teams use in production.
You've learned how to:
- Build a Spring Boot REST API with a unit test and package it using a multi-stage Dockerfile that produces a minimal production image.
- Create a GitHub Actions workflow that automatically tests, builds, and pushes a Docker image on every commit to main.
- Publish a container image to GitHub Container Registry tagged with the Git commit SHA for full traceability.
- Added container security scanning with Trivy to catch critical vulnerabilities before deployment.
Ready to quiz yourself?