DevSecOps Pipeline with GitHub Actions

Build a CI/CD pipeline that automatically blocks vulnerable Docker images.

Introduction

30 Second Summary

Every container image your team deploys could carry known security exploits hidden inside its operating system packages or libraries. In production environments, catching those vulnerabilities before deployment is not optional.

In this project, you will build a CI/CD pipeline using GitHub Actions that automatically builds a Docker image, scans it for vulnerabilities with Trivy, and blocks unsafe images from progressing.

What You'll Build

A complete DevSecOps pipeline that builds, scans, and gates a containerized Node.js application on every push to the main branch.

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

  • A GitHub Actions workflow that automatically builds a Docker image every time you push code to main.
  • Trivy vulnerability scanning integrated into your pipeline, blocking any image that contains CRITICAL or HIGH severity vulnerabilities.
  • A proven security gate demonstrated by intentionally failing the pipeline with a vulnerable base image, then fixing it to pass.
  • Secret Mission: Push clean images to Azure Container Registry, connecting your pipeline to your cloud environment.

Are there any prerequisites?

You need a GitHub account, Docker Desktop installed on Windows, and Git installed on your machine. No prior experience with CI/CD pipelines or container security is required.

Set Up Your Project

Every CI/CD pipeline needs something to build. Before you can automate security scanning, you need a working application with its source code hosted in a repository that GitHub Actions can access.

In this step, you will create a GitHub repository, set up a simple Node.js health-check server, and confirm Docker Desktop is ready to build container images.

In this step, get ready to:
  • Create a GitHub repository and Node.js application for your pipeline.
  • Verify that Docker Desktop is running on your machine.
  • Push your initial code to the main branch on GitHub.
Create your repository and application code

You need a public GitHub repository so that GitHub Actions can run workflows for free. You will create the repository on GitHub, clone it to your machine, and then add your application files.

  • Open your browser and go to github.com.
  • In the upper-right corner of any page, select the + menu, then click New repository.
  • In the Repository name box, type devsecops-pipeline.
  • Select Public for the repository visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why a public repository?

GitHub Actions provides unlimited free minutes for public repositories. A private repository would consume your account's monthly free-tier allowance of 2,000 minutes instead.

GitHub will show you a Quick Setup page with your repository URL. Keep this page open for the next command.

  • Open your terminal. Press the Windows key, type cmd, and press Enter to open Command Prompt.
  • Move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
  • Clone your new repository and move into it by running these commands:
git clone [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/devsecops-pipeline.git"]]
cd devsecops-pipeline

Where do I find my repository URL?

On the Quick Setup page that appeared after you created the repository, copy the HTTPS URL. It looks like https://github.com/YOUR_USERNAME/devsecops-pipeline.git.

  • Initialize a Node.js project by running this command:
npm init -y

Seeing 'npm' is not recognized?

This means Node.js is not installed on your machine. Download and install it from nodejs.org. After installation, close and reopen your terminal, then try the command again.

Help me fix npm not recognized on Windows

The npm init -y command created a default package.json file. You need to replace its contents with the exact configuration your project needs.

  • Open the devsecops-pipeline folder in your code editor.
  • Open package.json and replace its entire contents with the following:
{
  "name": "devsecops-pipeline",
  "version": "1.0.0",
  "description": "A DevSecOps pipeline demo with GitHub Actions and Trivy",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "4.19.2"
  }
}

What does this configuration do?

  • The scripts.start field tells Node.js how to run your application.
  • The dependencies field pins Express to version 4.19.2, a stable web framework for building your health-check endpoint.
  • Save package.json.
  • Install the Express dependency by running this command in your terminal:
npm install
  • You should see output mentioning packages added and a new node_modules folder appears in your project directory.

npm install failed?

Make sure you saved package.json before running npm install. Check for any typos in the JSON. A misplaced comma or missing quote will cause a parse error.

Help me fix npm install errors

Now create the .gitignore file to keep unnecessary files out of your repository.

  • Create a new file named .gitignore in the devsecops-pipeline folder and add the following content:
node_modules/
.env
  • Save .gitignore.

Next, create the application itself. This is a minimal Express server with a single health-check endpoint that your pipeline will eventually containerize and scan.

  • Create a new file named app.js in the devsecops-pipeline folder and add the following code:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version || '1.0.0'
  });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

  • It creates an Express web server that listens on port 3000.
  • The /health endpoint returns a JSON response with the server status, current timestamp, and app version.
  • This endpoint is what automated monitoring tools (and eventually your pipeline) use to confirm the application is running correctly.
  • Save app.js.
  • Test the server by running this command:
npm start
  • You should see Server running on port 3000 in your terminal.
  • Open your browser and go to http://localhost:3000/health.
  • You should see a JSON response with "status": "healthy".
  • Stop the server by pressing Ctrl+C in your terminal.

Server not starting?

If you see an error about a missing module, npm install did not run successfully. Run it again and check for errors in the output.

Help me fix my Node.js server

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.js:

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version || '1.0.0'
  });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Here is the complete package.json:

{
  "name": "devsecops-pipeline",
  "version": "1.0.0",
  "description": "A DevSecOps pipeline demo with GitHub Actions and Trivy",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "4.19.2"
  }
}

Here is the complete .gitignore:

node_modules/
.env
Verify Docker Desktop is running

Your pipeline will build Docker images in a later step. For now, confirm that Docker Desktop is ready on your machine.

  • Check your Docker version by running this command:
docker --version
  • You should see output like Docker version 27.x.x (the exact number may differ).
  • Verify Docker can communicate with its background service by running:
docker info
  • You should see detailed output about your Docker installation including Server Version, Operating System, and Total Memory. No errors means Docker is ready.

✔️ I see version information and no errors

Docker is ready. Move on to the next substep.

ⓧ I see an error about the Docker daemon

This means Docker Desktop is not running. Look for the Docker Desktop icon in your system tray (bottom-right of your taskbar). If it is not there, open Docker Desktop.

  • Press the Windows key, type Docker Desktop, and press Enter.
  • Wait for the Docker Desktop dashboard to fully load (the whale icon in the system tray stops animating).
  • Run docker info again to confirm it works.

Help me start Docker Desktop

Push your initial code to GitHub

Your application files are ready locally. Now push them to your GitHub repository so that GitHub Actions can access the code when you create your pipeline.

  • Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Initial commit: Node.js health-check app"
git push origin main

What do these commands do?

  • git add . stages all new files (app.js, package.json, .gitignore, and package-lock.json) for commit.
  • git commit saves a snapshot of your staged files with a descriptive message.
  • git push origin main uploads your local commits to the main branch on GitHub.
  • Go to your repository page on GitHub (https://github.com/YOUR_USERNAME/devsecops-pipeline) and refresh the page.
  • You should see app.js, package.json, package-lock.json, and .gitignore listed in your repository.

Push was rejected?

If you accidentally initialized the repository with a README on GitHub, you will have a conflict. The fastest fix is to delete the repository on GitHub, recreate it without a README, and push again.

Help me fix a rejected git push

Your project foundation is in place. Next up, you will write a security-hardened Dockerfile that packages this application into a container image.

Write a Security-Hardened Dockerfile

Your repository is set up and your Node.js health-check app is ready to go. Now it's time to package that app into a Docker container.

A Dockerfile tells Docker exactly how to build your container image. In this step, you'll write one that follows production security best practices: a minimal base image, multi-stage builds, and a non-root user.

In this step, get ready to:
  • Create a multi-stage Dockerfile with a minimal Alpine base image.
  • Test the containerized app locally.
  • Push the Dockerfile to your GitHub repository.
Create the build stage of your Dockerfile

A multi-stage build splits your Dockerfile into separate stages. The first stage installs dependencies, and the final stage copies only what the app needs to run. This keeps the final image small and secure.

  • In your code editor, create a new file called Dockerfile (no file extension) in the root of your devsecops-pipeline folder.
  • Paste in the following code for the build stage:
# Build stage: install production dependencies
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

What does this code do?

  • FROM node:22-alpine AS build starts a named build stage using a minimal Alpine Linux base image with Node.js 22 pre-installed.
  • WORKDIR /app sets the working directory inside the container so all following commands run from /app.
  • COPY package*.json ./ copies your package.json (and package-lock.json if it exists) into the container.
  • RUN npm ci --omit=dev installs only production dependencies. The --omit=dev flag skips devDependencies, keeping the image lean.

What is a multi-stage build?

A multi-stage build uses multiple FROM statements in one Dockerfile. Each stage can do heavy work (installing tools, compiling code), but only the files you explicitly copy forward end up in the final image.

This means your production container doesn't carry build tools, caches, or other artifacts that attackers could exploit.

Add the production stage with security hardening

The production stage creates the actual image that will run in your pipeline. It copies only the installed dependencies from the build stage, runs as a non-root user, and exposes the app's port.

  • Below the build stage you just added in Dockerfile, add the production stage:
# Production stage: create the minimal runtime image
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY app.js ./
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "app.js"]

What does this code do?

  • FROM node:22-alpine starts a completely fresh image. Nothing from the build stage carries over unless you explicitly copy it.
  • COPY --from=build pulls only the pre-installed node_modules from the build stage.
  • USER node switches to the built-in non-root node user that Alpine's Node.js image already includes. This means if an attacker breaks into your container, they don't have root access.
  • EXPOSE 3000 documents which port the app listens on.
  • CMD ["node", "app.js"] starts the application when the container runs.
  • Save Dockerfile.

Seeing a syntax error?

  • Make sure your file is named exactly Dockerfile with a capital D and no file extension. Not Dockerfile.txt or dockerfile.
  • Check that there is a blank line separating the build stage from the production stage.
  • Verify COPY --from=build has two dashes before from.

Help me troubleshoot my Dockerfile syntax

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile for comparison:

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY app.js ./
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "app.js"]
Test the Dockerfile locally

Before pushing to GitHub, you want to confirm the image builds successfully and the health endpoint responds. This catches issues before they reach your pipeline.

  • Build the Docker image by running this command in your terminal (make sure you are inside the devsecops-pipeline folder):
docker build -t devsecops-app:local .

What does this command do?

  • docker build tells Docker to read your Dockerfile and create an image from it.
  • -t devsecops-app:local tags (names) the image so you can reference it easily.
  • . tells Docker to look in the current directory for the Dockerfile.
  • Wait for the build to finish. You should see output ending with a line that includes devsecops-app:local confirming the image was tagged.

Build failing?

  • Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray.
  • Check that your terminal's current directory contains the Dockerfile. Run ls (or dir on Windows) to confirm.
  • Verify your package.json is saved and contains the Express dependency.

Help me fix my Docker build error

  • Run the container to test your health endpoint:
docker run --rm -p 3000:3000 devsecops-app:local

What does this command do?

  • --rm automatically removes the container when it stops, keeping things clean.
  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container.
  • You should see Server running on port 3000 in your terminal. The container is now running.
  • Open a new browser tab and navigate to http://localhost:3000/health.
  • You should see a JSON response with "status": "healthy", a timestamp, and a version number.
  • Return to your terminal and press Ctrl+C to stop the container.

Not seeing the JSON response?

  • If the browser shows "This site can't be reached", make sure the container is still running in your terminal (you should see the Server running on port 3000 message).
  • Check that nothing else is using port 3000. If another process is using that port, stop it first.
  • Make sure you're hitting /health (not just localhost:3000). The app only responds on the /health endpoint.

Help me debug my Docker container

Push the Dockerfile to GitHub

Your Dockerfile works locally. Now push it to your repository so that your CI/CD pipeline (which you'll create next) can use it.

  • Commit and push your Dockerfile by running these commands:
git add Dockerfile
git commit -m "Add security-hardened Dockerfile"
git push origin main
  • Go to your devsecops-pipeline repository on GitHub and confirm you see the Dockerfile listed alongside your other files.

Push rejected?

  • If Git says your branch is behind, run git pull origin main first, then try pushing again.
  • Make sure you're on the main branch. Run git branch to check.

Help me push my code to GitHub

Your containerized app is built, tested, and pushed. Next up, you'll create a GitHub Actions workflow that automates this build process on every push to your repository.

Create a GitHub Actions CI/CD Workflow

You have a containerized application with a security-hardened Dockerfile. The next challenge is making sure every code change gets built and validated automatically, without anyone needing to run manual commands.

GitHub Actions lets you define automated workflows that trigger on events like pushing code. In this step, you'll create a CI/CD pipeline that builds your Docker image every time you push to the main branch.

In this step, get ready to:
  • Create the workflow directory and configuration file.
  • Define pipeline steps that check out code and build a Docker image.
  • Push the workflow and confirm it runs successfully on GitHub.
Create the workflow directory and file

GitHub Actions looks for workflow files inside a specific folder structure: .github/workflows/. Any YAML file in this directory becomes a pipeline definition.

  • Create the required directory structure by running this command in your terminal (inside your devsecops-pipeline folder):
mkdir -p .github/workflows

What if I'm using PowerShell?

PowerShell's mkdir creates nested directories automatically. Running mkdir .github/workflows (without -p) works the same way in PowerShell.

  • Create a new file inside that directory called devsecops.yml by running:
touch .github/workflows/devsecops.yml
  • Confirm the file exists by running:
ls .github/workflows/

You should see devsecops.yml listed in the output.

  • Open .github/workflows/devsecops.yml in your code editor and paste the following workflow configuration:
# Define the workflow name
name: DevSecOps Pipeline

# Trigger this workflow on every push to main
on:
  push:
    branches:
      - main

# Define the jobs that run in this workflow
jobs:
  build-and-scan:
    name: Build and Scan
    runs-on: ubuntu-24.04

What does this configuration do?

  • name: DevSecOps Pipeline gives the workflow a human-readable label that appears in the GitHub Actions tab.
  • on: push: branches: - main tells GitHub to run this workflow every time code is pushed to the main branch.
  • jobs: build-and-scan defines a single job. Jobs are the units of work in a workflow.
  • runs-on: ubuntu-24.04 tells GitHub to run this job on a fresh Ubuntu virtual machine.
Add the pipeline steps

A job needs steps to actually do something. Each step is a discrete action in your pipeline. You need two steps: one to grab your code, and one to build the Docker image.

  • In devsecops.yml, add the following directly below the runs-on: ubuntu-24.04 line:
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .

What do these steps do?

  • actions/checkout@v4 is a pre-built GitHub Action that clones your repository onto the runner. Without this, the virtual machine has no access to your code.
  • docker build -t devsecops-app:${{ github.sha }} builds your Docker image using the Dockerfile in your repo. The ${{ github.sha }} tag is the unique commit hash, so every build is traceable to the exact code that produced it.
  • Save .github/workflows/devsecops.yml.

YAML not looking right?

YAML is indentation-sensitive. Make sure steps: is indented exactly 4 spaces under build-and-scan:. Each - name: entry should be indented 6 spaces from the left margin.

help me fix YAML indentation in my GitHub Actions workflow

✔️ 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

name: DevSecOps Pipeline

on:
  push:
    branches:
      - main

jobs:
  build-and-scan:
    name: Build and Scan
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .
Commit and push your workflow

Your workflow file only takes effect once it lands on the main branch in your GitHub repository. Pushing it triggers the workflow automatically.

  • Commit and push your workflow file by running these commands:
git add .github/workflows/devsecops.yml
git commit -m "Add CI/CD workflow"
git push origin main
  • Open your devsecops-pipeline repository on GitHub in your browser.
  • Click the Actions tab at the top of the page.
  • You should see a workflow run in progress or completed. Click on it to view the details.

The workflow should complete with a green checkmark. This confirms your Docker image builds successfully on GitHub's servers.

What should I see in the Actions tab?

You'll see a list of workflow runs. The most recent one should be labelled with your commit message ("Add CI/CD workflow"). Click into it and you'll see the Build and Scan job with its two steps: Checkout code and Build Docker image. Both should show green checkmarks.

Seeing a red X instead of a green checkmark?

Click on the failed run to see the error logs. Common causes:

  • YAML syntax error: check your indentation. Every level is exactly 2 spaces relative to its parent.
  • Dockerfile not found: make sure your Dockerfile is committed and pushed to the main branch (not just saved locally).
  • Branch mismatch: confirm you pushed to main, not a different branch.

help me debug my failed GitHub Actions workflow

Your pipeline builds the Docker image on every push, but it does not check for security issues yet. Next up, you'll add Trivy vulnerability scanning to catch dangerous vulnerabilities before they reach production.

Add Trivy Vulnerability Scanning

Your pipeline already builds a Docker image on every push to main. But building an image is only half the story.

A production pipeline needs to verify that the image is safe before it goes any further. In this step, you will add Trivy, an open-source vulnerability scanner, to your workflow. Trivy checks every OS package and application library inside your image against a database of known security vulnerabilities.

In this step, get ready to:
  • Add a Trivy scanning step to your GitHub Actions workflow.
  • Push the updated workflow and verify the pipeline passes end-to-end.
  • Understand how Trivy protects your pipeline from vulnerable images.
Add the Trivy scanning step to your workflow

Trivy needs to run after the Docker image is built so it has something to scan. You will add it as a new step in your existing workflow file.

  • In your code editor, open .github/workflows/devsecops.yml.
  • Find the Build Docker image step at the bottom of the file:
      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .
  • Add the following Trivy scanning step directly below the build step:
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: 'devsecops-app:${{ github.sha }}'
          format: 'table'
          exit-code: '1'
          ignore-unfixed: true
          vuln-type: 'os,library'
          severity: 'CRITICAL,HIGH'

What does this configuration do?

  • image-ref tells Trivy which image to scan. It matches the tag from your build step.
  • format: 'table' outputs results as a readable table in the workflow logs.
  • exit-code: '1' makes Trivy return a non-zero exit code when it finds vulnerabilities matching your severity filter. This causes the entire workflow to fail.
  • ignore-unfixed: true skips vulnerabilities that have no available patch yet.
  • vuln-type: 'os,library' scans both operating system packages (Alpine's apk packages) and application libraries (your npm dependencies).
  • severity: 'CRITICAL,HIGH' sets the threshold. Only CRITICAL and HIGH findings will trigger a failure.
  • Save devsecops.yml.

YAML indentation errors?

YAML is sensitive to spacing. The - name: Run Trivy vulnerability scanner line must be indented to the same level as - name: Build Docker image above it (6 spaces). If your editor uses tabs instead of spaces, check that the file uses spaces throughout.

Help me fix my YAML indentation in devsecops.yml

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete devsecops.yml file after adding the Trivy step:

name: DevSecOps Pipeline

on:
  push:
    branches:
      - main

jobs:
  build-and-scan:
    name: Build and Scan
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: 'devsecops-app:${{ github.sha }}'
          format: 'table'
          exit-code: '1'
          ignore-unfixed: true
          vuln-type: 'os,library'
          severity: 'CRITICAL,HIGH'
Push the updated workflow and verify the scan

Now you need to commit and push so GitHub Actions picks up the updated workflow file and runs the full pipeline.

  • Commit and push your changes by running these commands in your terminal:
git add .github/workflows/devsecops.yml
git commit -m "Add Trivy vulnerability scanning step"
git push origin main
  • Switch back to your GitHub repository in your browser.
  • Click the Actions tab at the top of the repository.
  • Click on the latest workflow run (it will show your commit message "Add Trivy vulnerability scanning step").
  • Click on the Build and Scan job to expand its steps.

You should see a green checkmark next to every step, including the new Run Trivy vulnerability scanner step. Click on it to expand the logs.

The logs show a table with column headers like Library, Vulnerability, Severity, and Fixed Version. With the node:22-alpine base image, you should see either no results or only LOW/MEDIUM findings that do not trigger the exit code.

Pipeline failed with a red X?

If Trivy found CRITICAL or HIGH vulnerabilities, the exit-code: '1' setting caused the workflow to fail. This can happen if a new CVE was published for an Alpine package since the image was last updated. Check the Trivy output table to see which package triggered it.

If you see a YAML syntax error instead of Trivy output, double check the indentation of your devsecops.yml file against the full code tab above.

Help me debug my failed Trivy scanning workflow

Understand how Trivy protects your pipeline

Take a moment to understand what just happened behind the scenes.

What did Trivy actually do?

Trivy downloaded a vulnerability database containing thousands of known CVEs (Common Vulnerabilities and Exposures). It then scanned every OS package installed in your Alpine image and every npm library in your node_modules folder.

Each package was compared against the database. If a match is found with CRITICAL or HIGH severity, the exit-code: '1' setting tells Trivy to return a non-zero exit code. GitHub Actions interprets any non-zero exit code as a failure and stops the workflow with a red X.

This is your security gate. A vulnerable image is blocked from progressing any further in the pipeline.

Why shift-left security?

Traditional security reviews happen at the end of development, right before deployment. By scanning in CI/CD (the pipeline that runs on every push), you catch vulnerabilities the moment code is committed. This is called shift-left because you move security checks earlier (leftward) in the development timeline.

Fixing a vulnerability at build time is far cheaper than discovering it in production.

Your pipeline now builds and scans every image automatically. Next up, you will put this security gate to the test by intentionally introducing a vulnerable base image and watching Trivy catch it.

Test the Security Gate

Your pipeline builds and scans images automatically. But how do you know the security gate actually blocks dangerous code?

In this step, you will intentionally break the pipeline by introducing a vulnerable Docker image. Then you will fix it and watch the pipeline recover. This proves that your Trivy scanner catches real vulnerabilities before they reach production.

In this step, get ready to:
  • Create a deliberately vulnerable Dockerfile using an end-of-life Node.js base image.
  • Watch the pipeline fail when Trivy detects CRITICAL and HIGH CVEs.
  • Revert to the secure Dockerfile and confirm the pipeline passes again.
Create a vulnerable Dockerfile and trigger the scan

Node.js 14 reached end-of-life in 2023 and has not received security patches since. The full node:14 Docker image contains dozens of known CRITICAL and HIGH vulnerabilities, making it a perfect test case.

  • Create a new file named Dockerfile.vulnerable in the root of your devsecops-pipeline folder with this code:
FROM node:14
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY app.js ./
EXPOSE 3000
CMD ["node", "app.js"]

What does this Dockerfile do differently?

Compare this to your secure Dockerfile. This vulnerable version uses node:14 (a full Debian-based image with hundreds of OS packages) instead of node:22-alpine (a minimal image with far fewer packages).

It also skips every security hardening step: no multi-stage build, no non-root user, and npm install instead of npm ci --omit=dev. This is exactly the kind of Dockerfile that a security gate should catch.

  • Save Dockerfile.vulnerable.

✔️ Awesome, I've got everything!

Great. Make sure you have saved Dockerfile.vulnerable in the same folder as your original Dockerfile.

ⓧ I'd like to double check the full code

FROM node:14
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY app.js ./
EXPOSE 3000
CMD ["node", "app.js"]

Now you need to tell your workflow to build from this vulnerable file instead of the secure one.

  • In .github/workflows/devsecops.yml, find the Build Docker image step. Locate this line:
        run: docker build -t devsecops-app:${{ github.sha }} .
  • Replace it with this line, which adds the -f Dockerfile.vulnerable flag to point Docker at your vulnerable file:
        run: docker build -f Dockerfile.vulnerable -t devsecops-app:${{ github.sha }} .

What does the -f flag do?

By default, docker build looks for a file named Dockerfile in the current directory. The -f flag overrides this and tells Docker to use a specific file instead.

  • Save devsecops.yml.
  • Commit and push both files by running these commands in your terminal:
git add .
git commit -m "Add vulnerable Dockerfile for security gate test"
git push

Push rejected or authentication error?

Make sure you are on the main branch. Run git branch to confirm. If you see a credentials error, re-authenticate with GitHub using your personal access token or SSH key.

Help me troubleshoot this push error

Watch the security gate block the vulnerable image

Your push triggers the pipeline automatically. This time, Trivy will scan the node:14 image and find dozens of known vulnerabilities that have never been patched.

  • Go to your devsecops-pipeline repository on GitHub.
  • Click the Actions tab at the top.
  • Click on the most recent workflow run (it should show your commit message "Add vulnerable Dockerfile for security gate test").
  • Click on the Build and Scan job to expand the step logs.

The workflow will fail with a red X. This is exactly what you want to see.

What should I see in the Trivy output?

Expand the Run Trivy vulnerability scanner step in the logs. You will see a table listing detected vulnerabilities with columns for the package name, vulnerability ID (CVE number), severity level (CRITICAL or HIGH), and whether a fix is available.

The exit-code: '1' setting in your workflow causes Trivy to return a non-zero exit code when it finds vulnerabilities matching your severity filter. GitHub Actions interprets any non-zero exit code as a failure and marks the run with a red X.

  • Scroll through the Trivy table in the logs. Note a few of the CVE IDs and package names you see listed.

Reading CVE identifiers

Each CVE follows the format CVE-YEAR-NUMBER (for example, CVE-2023-44487). The year tells you when the vulnerability was disclosed. Seeing CVEs from 2023 or earlier in a 2026 scan means these issues have been public knowledge for years with no patch available for this EOL image.

Revert to the secure Dockerfile and confirm the fix

You have proven that the gate catches vulnerable images. Now revert the workflow to use your hardened Dockerfile and confirm that a clean image passes the scan.

  • In .github/workflows/devsecops.yml, find the Build Docker image step. Locate this line:
        run: docker build -f Dockerfile.vulnerable -t devsecops-app:${{ github.sha }} .
  • Replace it with the original line that builds from the default Dockerfile:
        run: docker build -t devsecops-app:${{ github.sha }} .
  • Save devsecops.yml.
  • Commit and push the revert by running:
git add .
git commit -m "Revert to secure Dockerfile"
git push
  • Return to the Actions tab in your GitHub repository.
  • Watch the new workflow run. It should complete with a green checkmark.

You now have one failed run (red X) and one successful run (green checkmark) side by side in your Actions history. This is proof that your security gate works.

Pipeline still failing after reverting?

Open devsecops.yml and confirm the build line reads exactly docker build -t devsecops-app:${{ github.sha }} . with no -f flag. Make sure you saved the file and pushed the commit.

Help me figure out why my pipeline is still failing

✔️ Awesome, I've got everything!

Great. Double check you have saved devsecops.yml and that it uses the original Dockerfile (no -f flag).

ⓧ I'd like to double check the full code

name: DevSecOps Pipeline

on:
  push:
    branches:
      - main

jobs:
  build-and-scan:
    name: Build and Scan
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: 'devsecops-app:${{ github.sha }}'
          format: 'table'
          exit-code: '1'
          ignore-unfixed: true
          vuln-type: 'os,library'
          severity: 'CRITICAL,HIGH'

You have just proven that your DevSecOps pipeline works end to end. A vulnerable image was blocked, and a clean image was allowed through. This is the shift-left security principle in action.

Secret mission

Push Clean Images to Azure Container Registry

Your pipeline blocks vulnerable images. But where do the clean ones go? In production, scanned images get pushed to a container registry so other services can pull and deploy them. Extend your pipeline to push passing images to Azure Container Registry.

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.

The GitHub repository and Actions workflow are free for public repositories. If you completed the Secret Mission, your Azure Container Registry (Basic SKU) costs approximately $0.167/day (~$5/month).

Cost warning

If you completed the Secret Mission, your Azure Container Registry incurs ongoing charges (~$5/month for Basic SKU). If you are not on a free Azure trial, consider deleting it to avoid unexpected bills.

Resources you used:

  • GitHub repository devsecops-pipeline (free, no ongoing cost).
  • Local project folder devsecops-pipeline on your Desktop.
  • Local Docker images built during the project.
  • Azure Container Registry (Secret Mission only, Basic SKU).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the pipeline as a portfolio piece.

  • Your GitHub repository and Actions workflow cost nothing for public repositories.
  • Your local Docker images take up disk space but do not incur any charges.
  • If you completed the Secret Mission, your Azure Container Registry (Basic SKU) costs approximately $0.167/day. If you are on a free Azure trial, this is covered.

Pause - I'll come back to this later

Free up local disk space while keeping your cloud resources intact.

  • No action needed for the GitHub repository. It remains available at no cost.
  • Remove local Docker images to reclaim disk space by running:
docker rmi devsecops-app:local
docker image prune -f
  • The Azure Container Registry does not have a pause option. Costs are minimal (~$5/month) but ongoing.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

Delete local Docker images
  • Remove the Docker images built during this project by running:
docker rmi devsecops-app:local
docker image prune -f
Delete the local project folder
  • On macOS/Linux, run:
cd ~/Desktop
rm -rf devsecops-pipeline
  • On Windows, run:
cd ~\Desktop
rmdir /s /q devsecops-pipeline
Delete the GitHub repository
  • Go to your devsecops-pipeline repository on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll down to the Danger Zone section at the bottom of the page.
  • Click Delete this repository and follow the confirmation prompts.
Delete the Azure Container Registry (Secret Mission only)
  • In the Azure Portal, navigate to your Container Registry resource.
  • Click Overview in the left sidebar.
  • Click Delete in the top toolbar and confirm the deletion.

Nice Work!

Nice Work!

You just built a production-style DevSecOps pipeline that automatically catches vulnerable container images before they can progress. Every push to main now triggers a security-gated build process.

You learned how to:

  • Built a containerized Node.js application with a security-hardened Dockerfile using multi-stage builds, a non-root user, and a minimal Alpine base image.
  • Created a GitHub Actions CI/CD pipeline that automatically builds and scans Docker images on every push to main.
  • Integrated Trivy vulnerability scanning with a security gate that blocks images containing CRITICAL or HIGH CVEs, and proved it works by intentionally failing the pipeline with a vulnerable base image.
  • Secret Mission: Connected the pipeline to Azure Container Registry, pushing only clean images to the cloud after passing the security scan.

Ready to quiz yourself?