Quality Gate CI Pipeline for Next.js

Build a GitHub Actions CI pipeline that auto-catches bugs in a Dockerized Next.js app.

Introduction

30 Second Summary

Every developer has pushed code that accidentally broke something. In a team, that broken code can reach users before anyone notices.

In this project, you will build a CI pipeline that automatically lints, tests, and builds your Next.js app every time you push to GitHub. You'll prove this by deliberately pushing broken code, first with no safety net, then with the pipeline catching it.

What You'll Build

Push code to GitHub, open the Actions tab, and watch green checkmarks appear as your pipeline lints, tests, and builds a production Docker image.

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

  • A Next.js app with unit tests you can run locally and watch pass in your terminal.
  • A production-optimized Docker image built with multi-stage builds, shrinking your app from ~1 GB to ~110 MB.
  • A GitHub Actions workflow that automatically runs ESLint, Vitest, and a Docker build every time you push.
  • The hands-on experience of pushing broken code and watching your pipeline catch it in real time.
  • Secret Mission: Add Playwright end-to-end testing to your app and CI pipeline.

Are there any prerequisites?

This project assumes basic familiarity with HTML, CSS, JavaScript, and the terminal. You'll also need Node.js, Docker, and a GitHub account ready on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Next.js Project with Testing Tools

A quality gate pipeline needs code to protect. Before you can write tests or build containers, you need a working app with testing tools installed.

In this step, you'll create a Next.js project from scratch and configure Vitest as your test runner. By the end, your app will be live in the browser and ready for its first unit tests.

In this step, get ready to:
  • Create a Next.js project with TypeScript, ESLint, and Tailwind CSS.
  • Install and configure Vitest with React Testing Library.
  • Run the development server and confirm everything works.
Create a new Next.js app
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project and move into it by running these commands:
npx create-next-app@latest nextjs-quality-gate --yes --use-npm
cd nextjs-quality-gate

The scaffold takes a minute or two while packages download and install.

What do these flags do?

The --yes flag accepts the recommended defaults without prompting you. You get TypeScript, ESLint, Tailwind CSS, and App Router out of the box.

The --use-npm flag tells the scaffolder to use npm as the package manager instead of yarn or pnpm.

  • Confirm the project was created by running:
ls

You should see folders like app, public, and node_modules, plus config files like package.json and next.config.ts.

Don't see the project files?

  • Make sure you ran cd nextjs-quality-gate after the scaffold command finished. Running ls from ~/Desktop shows only the folder name, not its contents.
  • If you see a network error, check your internet connection and try the scaffold command again.
  • Help me troubleshoot my Next.js project setup.
Install and configure Vitest
  • Install the testing libraries as dev dependencies by running this command:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths

What are these packages?

  • vitest is the test runner. It is fast, lightweight, and built on the same engine as your project's build tool.
  • @vitejs/plugin-react and jsdom let Vitest render React components in a simulated browser environment.
  • @testing-library/react and @testing-library/dom give you utilities to query and interact with rendered components the way a real user would.
  • vite-tsconfig-paths resolves TypeScript path aliases so your tests can use the same import paths as your app code.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the nextjs-quality-gate folder on your Desktop and click Open.

Trust the folder

VS Code may show a trust banner or dialog asking if you trust the authors of this folder. Select Yes, I trust the authors to enable all editor features.

You should see the project files in VS Code's Explorer sidebar on the left.

  • In the Explorer sidebar, hover over the project folder name to reveal the action icons.
  • Click the New File icon (the file with a plus sign).
  • Type vitest.config.mts and press Enter.
  • Paste the following code into vitest.config.mts:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: "jsdom",
  },
});

What does this config do?

  • tsconfigPaths() resolves TypeScript path aliases so your tests use the same imports as your app.
  • react() handles JSX transformation inside the test environment.
  • environment: "jsdom" simulates a browser DOM so you can render and test React components without a real browser.
  • Save vitest.config.mts.
  • In the Explorer sidebar, confirm you see vitest.config.mts listed in the project root.

✔️ Awesome, I've got everything!

You're all set. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete vitest.config.mts file:

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: "jsdom",
  },
});
  • Open package.json from the Explorer sidebar.
  • Find the last entry in the "scripts" section. It looks like this:
    "lint": "eslint"
  • Add a comma after "eslint", then add the test script on the next line so the section ends like this:
    "lint": "eslint",
    "test": "vitest"
  • Save package.json.
Start the development server

Everything is installed and configured. Before you start the server, think about what you expect to see when you visit http://localhost:3000. This is a brand new Next.js project with no custom code yet.

  • Switch back to your terminal window.
  • Start the development server by running:
npm run dev

What happens when you run this?

The dev server takes over your terminal and keeps running until you stop it. Look for http://localhost:3000 in the output to confirm it started successfully.

  • Open http://localhost:3000 in your browser.

You should see the default Next.js starter page with the Next.js logo and introductory content. That confirms your project is running correctly.

Don't see the starter page?

  • Make sure the dev server is still running in your terminal. If you closed the terminal, open it again and run npm run dev from the nextjs-quality-gate folder.
  • If you see a message about port 3000 being in use, stop the other process using that port and try again.
  • Help me get my dev server running.
  • Stop the server by pressing Ctrl+C in your terminal.

Your Next.js app is running and your testing tools are ready. Next up, you'll write a real feature and your first unit tests to protect it.

Write a Testable Feature and Your First Unit Tests

Your Next.js project is set up with Vitest and React Testing Library ready to go. But right now, there is nothing to test.

A CI pipeline is only useful when there is testable code and passing tests to run. In this step, you'll replace the default starter page with a custom component and write your first unit tests for it.

In this step, get ready to:
  • Replace the default Next.js page with a testable component.
  • Write and run your first unit tests with Vitest.
Replace the default page with a testable component

Your tests will look for specific text and elements on the page. The default Next.js starter page changes between versions, so it is not a reliable test target.

  • Open app/page.tsx in your editor and replace the entire contents with this code:
export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      {/* Main heading - tests will check for this exact text */}
      <h1 className="text-4xl font-bold mb-4">Quality Gate Pipeline</h1>
      <p className="text-lg text-gray-600 mb-8">
        A Next.js app with automated testing and Docker deployment.
      </p>
      <section>
        <h2 className="text-2xl font-semibold mb-4">Features</h2>
        {/* Each list item contains exact text that tests will assert against */}
        <ul className="list-disc list-inside space-y-2">
          <li>ESLint for code quality</li>
          <li>Vitest for unit testing</li>
          <li>Docker for containerization</li>
          <li>GitHub Actions for CI</li>
        </ul>
      </section>
    </main>
  );
}

What does this code do?

  • The Home component renders a centered layout with a heading, a description paragraph, and a features section.
  • The <h1> contains the exact text "Quality Gate Pipeline". Your first test will search for this heading by role and name.
  • Each <li> contains a specific string like "ESLint for code quality". Your second test will verify all four of these strings appear on the page.
  • Save app/page.tsx.
  • Switch to your browser. The page should automatically refresh thanks to the dev server's hot reload.
  • You should see "Quality Gate Pipeline" as the main heading, a description paragraph, and a list of four features: ESLint, Vitest, Docker, and GitHub Actions.

Don't see the updated page?

  • Make sure you saved app/page.tsx. Unsaved changes will not trigger a browser refresh.
  • Confirm your dev server is still running. If you stopped it, start it again with npm run dev.
  • Check the terminal for any syntax errors. A missing closing tag or bracket will prevent the page from rendering.

Why isn't my updated page.tsx showing in the browser?

✔️ Awesome, I've got everything!

Great. Double check you've saved app/page.tsx and your browser shows the new page.

ⓧ I'd like to double check the full code

Here is the complete app/page.tsx file. Make sure yours matches exactly:

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-4xl font-bold mb-4">Quality Gate Pipeline</h1>
      <p className="text-lg text-gray-600 mb-8">
        A Next.js app with automated testing and Docker deployment.
      </p>
      <section>
        <h2 className="text-2xl font-semibold mb-4">Features</h2>
        <ul className="list-disc list-inside space-y-2">
          <li>ESLint for code quality</li>
          <li>Vitest for unit testing</li>
          <li>Docker for containerization</li>
          <li>GitHub Actions for CI</li>
        </ul>
      </section>
    </main>
  );
}
Write your first unit test

Unit tests verify that individual pieces of your app work as expected. You'll write a test that checks whether the main heading renders correctly.

  • Create a new folder called __tests__ in the root of your nextjs-quality-gate project.
  • Create a new file called page.test.tsx inside the __tests__ folder.
  • Add the following code to __tests__/page.test.tsx:
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import Page from "../app/page";

// Verify the main heading renders with the correct text
test("renders the main heading", () => {
  render(<Page />);
  expect(
    screen.getByRole("heading", { level: 1, name: "Quality Gate Pipeline" })
  ).toBeDefined();
});

What does this code do?

  • The imports pull in test and expect from Vitest, plus render and screen from React Testing Library.
  • render(<Page />) mounts the component in a simulated browser environment (the jsdom you configured in vitest.config.mts).
  • screen.getByRole("heading", { level: 1, name: "Quality Gate Pipeline" }) searches for an <h1> with that exact text. If it is not found, the test fails.
  • Save __tests__/page.test.tsx.
  • Run your first test by running this command in a new terminal:
npx vitest run

You should see output showing 1 test passed.

Test failing or command not found?

  • Make sure you are running the command from inside the nextjs-quality-gate folder, not from outside it.
  • If the test fails with a message about "Quality Gate Pipeline", check that your app/page.tsx heading text matches the test exactly.
  • If you see a module resolution error, confirm vitest.config.mts exists and includes the tsconfigPaths() plugin.

Why is my Vitest test failing?

Add a test for the features list

One test covers the heading, but the features list is the other key part of your component. A second test will verify all four feature items render.

  • In __tests__/page.test.tsx, add the following test below the closing }); of the first test:
// Verify all four feature items are present
test("renders the features list", () => {
  render(<Page />);
  expect(screen.getByText("ESLint for code quality")).toBeDefined();
  expect(screen.getByText("Vitest for unit testing")).toBeDefined();
  expect(screen.getByText("Docker for containerization")).toBeDefined();
  expect(screen.getByText("GitHub Actions for CI")).toBeDefined();
});

What does this test do?

  • screen.getByText() searches the rendered output for an element containing the exact string you pass in.
  • Each expect(...).toBeDefined() asserts that the element exists. If any of the four strings is missing from the page, the test fails immediately.
  • Save __tests__/page.test.tsx.

Before you run the tests, how many do you expect to pass?

  • Run both tests by running this command:
npm test

You should see output showing 2 tests passed. Press q to exit watch mode.

Why does Vitest stay running?

Vitest starts in watch mode by default. It watches your files for changes and re-runs tests automatically when you save. This is great during development. In the CI pipeline later, you will use npx vitest run to run tests once and exit.

Tests not passing?

  • Check that the strings in your test (like "ESLint for code quality") match the text in app/page.tsx exactly. A single character difference causes a failure.
  • Make sure the import path is ../app/page (going up one directory from __tests__ into the project root, then into app).
  • Confirm both test functions are complete with matching opening and closing braces and parentheses.

Help me debug my failing Vitest tests.

✔️ Awesome, I've got everything!

Great. Double check you've saved __tests__/page.test.tsx and both tests passed.

ⓧ I'd like to double check the full code

Here is the complete __tests__/page.test.tsx file. Make sure yours matches exactly:

import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import Page from "../app/page";

test("renders the main heading", () => {
  render(<Page />);
  expect(
    screen.getByRole("heading", { level: 1, name: "Quality Gate Pipeline" })
  ).toBeDefined();
});

test("renders the features list", () => {
  render(<Page />);
  expect(screen.getByText("ESLint for code quality")).toBeDefined();
  expect(screen.getByText("Vitest for unit testing")).toBeDefined();
  expect(screen.getByText("Docker for containerization")).toBeDefined();
  expect(screen.getByText("GitHub Actions for CI")).toBeDefined();
});
Confirm ESLint passes

Your tests pass, but tests are only one quality check. ESLint catches code style and correctness issues that tests do not cover.

  • Confirm your code passes the linter by running:
npm run lint

You should see no errors or warnings. If ESLint passes, your code meets the quality standards that create-next-app set up for you.

Seeing lint errors?

  • Read the error message carefully. It will tell you the file, line number, and rule that failed.
  • If you see errors in app/page.tsx, compare your file against the full code in the tab group above to find any differences.

Help me fix my ESLint errors.

You now have a testable component and two passing unit tests that prove it works. Next up, you'll package this app into a Docker container so it runs the same way everywhere.

Dockerize Your App with a Production Build

Your tests pass and ESLint is clean. Your app works on your machine. But how do you guarantee it runs the same way on a CI server, a teammate's laptop, or in production?

Docker solves this by packaging your app into a container. The same image runs identically everywhere. In this step, you'll create a production-optimized Docker image using multi-stage builds and Next.js standalone mode.

In this step, get ready to:
  • Configure Next.js standalone output for smaller Docker images.
  • Create a multi-stage Dockerfile and a .dockerignore file.
  • Build and run your app from a Docker container.
Enable standalone output in Next.js

A standard Next.js build bundles your entire node_modules folder into the output. That inflates a Docker image to roughly 1 GB. Standalone mode produces a self-contained build with only the files needed to run, shrinking the image to around 110 MB.

  • Open next.config.ts in your nextjs-quality-gate folder.
  • Replace the entire file contents with the following code:
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

What does standalone do?

The output: "standalone" setting tells Next.js to trace which files your app actually needs at runtime. It copies only those files into a .next/standalone directory, along with a minimal server.js that can run the app without the full node_modules.

  • Save next.config.ts.
  • Confirm standalone mode works by running a production build:
npm run build
  • Verify the standalone output was created by running:
ls .next/standalone

You should see files including server.js and a node_modules folder. This is the trimmed-down version of your app that Docker will use.

Don't see the standalone directory?

  • Make sure you saved next.config.ts before running the build.
  • Check that output: "standalone" is inside the nextConfig object, not outside of it.
  • Why isn't my Next.js standalone build working?
Create a multi-stage Dockerfile

A multi-stage Docker build uses separate stages for installing dependencies, building, and running. Each stage starts from a fresh base image, so only the files you explicitly copy forward end up in the final image.

  • Create a new file called Dockerfile (no extension) in the root of your nextjs-quality-gate folder.
  • Add the dependency and build stages by pasting this code into your new file:
ARG NODE_VERSION=24.13.0-slim

# Stage 1: Install dependencies
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

# Stage 2: Build the application
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build

What do these stages do?

  • The ARG line pins the Node.js base image version so every build uses the same version.
  • The dependencies stage copies only package.json and package-lock.json, then runs npm ci to install packages. Docker caches this layer, so dependencies only reinstall when those files change.
  • The builder stage pulls in the installed node_modules from stage 1, copies your source code, and runs npm run build to produce the standalone output.
  • Add the production runtime stage below the code you just pasted:
# Stage 3: Production runtime
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next
RUN chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

What does the runner stage do?

  • This stage starts from a clean image and copies only the production files: the public folder, the .next/standalone output, and the .next/static assets.
  • USER node switches to a non-root user for security. The --chown=node:node flags on each COPY give that user ownership of the copied files.
  • CMD ["node", "server.js"] starts the standalone server that Next.js generated in the build stage.
  • Save Dockerfile.

✔️ Awesome, I've got everything!

Great. Double check you've saved your Dockerfile.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile. Make sure your file matches exactly:

ARG NODE_VERSION=24.13.0-slim

# Stage 1: Install dependencies
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

# Stage 2: Build the application
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build

# Stage 3: Production runtime
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next
RUN chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

The Dockerfile also needs to know which files to ignore during the build. Without a .dockerignore, Docker would copy everything, including node_modules and the .next build cache, slowing things down and bloating the image.

  • Create a new file called .dockerignore in the root of your nextjs-quality-gate folder and paste this content:
node_modules
.next
.git
.github
Dockerfile
.dockerignore
README.md
*.md
tests
__tests__
playwright-report
test-results

What does .dockerignore do?

This file works like .gitignore but for Docker builds. It tells Docker to skip these files when copying your project into the image. This keeps the build context small and prevents local artifacts like node_modules from overwriting the clean install that happens inside the container.

  • Save .dockerignore.

Why multi-stage builds instead of a single Dockerfile?

A single-stage build would leave all your build tools, source code, and dev dependencies in the final image. Multi-stage builds let you use Node.js and npm during the build, then throw away everything except the production output.

The result is a lean image that contains only what your app needs to run. Smaller images start faster, use less disk, and have a smaller attack surface.

Build and run your Docker container

Everything is in place. Time to build your Docker image and run your app from inside a container.

  • Build the Docker image by running this command:
docker build -t nextjs-quality-gate .

This will take a minute or two. Docker downloads the Node.js base image, installs your dependencies, and builds your app across all three stages.

Seeing a build error?

  • Make sure Docker is running. Open Docker Desktop if it is not already started.
  • Check that Dockerfile is in the root of your nextjs-quality-gate folder, not inside a subfolder.
  • If you see a COPY failed error, make sure output: "standalone" is still in your next.config.ts. The build stage relies on it to produce the standalone output.
  • Why is my Docker build failing for my Next.js app?

When the build finishes, you should see a line at the end that says the image was successfully tagged. Now it is time to run it.

Before you run the container, think about this: do you expect the app to look the same running from inside a Docker container as it did from the dev server?

  • Start the container by running this command:
docker run -p 3000:3000 nextjs-quality-gate

What does -p 3000:3000 mean?

The -p 3000:3000 flag maps port 3000 on your machine to port 3000 inside the container. Without it, the app runs but you cannot reach it from your browser.

  • Open http://localhost:3000 in your browser.

You should see the same Quality Gate Pipeline page with the heading, description, and features list. This time, it is running from inside a Docker container, not your local dev server. The same image would produce the same result on any machine with Docker installed.

  • Press Ctrl+C in your terminal to stop the container.

App not loading at localhost:3000?

  • Make sure no other process is using port 3000. If you still have the dev server running from earlier, stop it first with Ctrl+C in that terminal.
  • Check the terminal output from docker run for any error messages. If it exited immediately, the image may not have built correctly. Re-run the build command.
  • Why isn't my Dockerized Next.js app loading in the browser?

Your app now runs from a portable, production-optimized Docker container. Next up, you'll push your code to GitHub and see what happens when broken code has no safety net.

Push to GitHub with No Safety Net

Your app is containerized, your tests pass, and your linter is clean. Everything looks production-ready.

Nothing stops you from pushing broken code straight to your repository right now. You're about to prove this by deliberately pushing a bug to GitHub and watching absolutely nothing stop you.

In this step, get ready to:
  • Push your project to a GitHub repository.
  • Deliberately break your tests and push broken code anyway.
  • See that nothing catches the broken code on GitHub.
Create a GitHub repository and push your project

Your code only lives on your machine right now. You need an empty repository on GitHub to push it to.

  • Open github.com in your browser.
  • Click the + icon in the top right corner, then click New repository.
  • Enter nextjs-quality-gate in the Repository name field.
  • Make sure Add a README file is unchecked. The repository must be completely empty.
  • Click Create repository.

GitHub shows you a quick setup page with commands. You'll use your own commands instead.

  • Back in your terminal, make sure you're inside the nextjs-quality-gate folder. Initialize a Git repository and push your code by running these commands:
git init
git add .
git commit -m "initial commit: app with tests and Docker"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/nextjs-quality-gate.git
git push -u origin main

What do these commands do?

  • git init creates a new Git repository in your project folder.
  • git add . stages every file for your first commit.
  • git branch -M main renames the default branch to main so it matches what GitHub expects.
  • git remote add origin connects your local repo to the GitHub repository you just created.
  • git push -u origin main uploads your code to GitHub and sets main as the default remote branch.
  • Go back to your GitHub repository page and refresh it.

You should see all your project files listed, including app/page.tsx, Dockerfile, and vitest.config.mts. Your code is now on GitHub.

Push failed or asking for authentication?

  • If Git asks for a username and password, GitHub no longer accepts passwords for command-line pushes. You need a Personal Access Token or SSH key. Follow GitHub's authentication docs to set one up.
  • If you see a "repository not found" error, double-check that your GitHub username in the remote URL matches your actual username exactly.

Help me fix a git push authentication error when pushing to GitHub.

Deliberately break a test and push the broken code

Time to prove that nothing protects your repository. You're going to break your app on purpose, confirm the tests fail locally, and then push the broken code anyway.

  • Open app/page.tsx and find this line:
<h1 className="text-4xl font-bold mb-4">Quality Gate Pipeline</h1>
  • Replace Quality Gate Pipeline with Oops I Broke It, so the line now looks like this:
<h1 className="text-4xl font-bold mb-4">Oops I Broke It</h1>
  • Save app/page.tsx.

Before you run the tests, what do you think will happen? Will both tests pass, will both fail, or will only one break?

  • Run the tests to find out:
npm test

The heading test fails. It's looking for Quality Gate Pipeline but finds Oops I Broke It instead. The features list test still passes because you didn't change those items.

Why does only one test fail?

Your first test uses getByRole to find a heading with the exact text Quality Gate Pipeline. Since you changed that text, the test can no longer find the heading and fails.

Your second test checks the features list items, which you didn't touch. It still passes.

  • Press q to exit watch mode.

You know the tests are broken. Push the code anyway.

  • Commit and push the broken code by running these commands:
git add .
git commit -m "oops: changed heading, tests probably fail"
git push

Git didn't complain. The push succeeded. Nobody checked whether the tests pass before accepting the code.

Before you check GitHub, do you think there will be any warnings or red marks next to the broken commit?

  • Open your GitHub repository in your browser and refresh the page.

The broken commit sits on main with no warnings, no errors, and no red marks. Anyone who pulls this code will get a broken app with failing tests, and GitHub gave zero indication that anything is wrong.

This is development without CI

What you just experienced is the exact problem that Continuous Integration exists to solve. Without automated checks, broken code reaches the main branch silently.

On a team, this means another developer could pull your broken code tomorrow and waste hours debugging a problem you already knew about. CI catches it before the code ever lands.

Broken code just shipped with zero resistance. In the next step, you'll build a CI pipeline that catches exactly this kind of bug before it ever reaches main.

Build Your CI Pipeline and Catch the Bug

In the last step, you pushed broken code to main and nothing stopped it. No warnings, no errors, no automated checks. That broken commit is sitting in your repository right now, looking perfectly fine.

That is the exact problem Continuous Integration solves. In this step, you will create a GitHub Actions workflow that automatically runs your linter, tests, and Docker build every time you push code. Then you will break the code again and watch the pipeline catch it.

In this step, get ready to:
  • Fix the broken heading and verify your tests pass locally.
  • Create a GitHub Actions CI workflow that runs lint, tests, and Docker build on every push.
  • Push broken code and watch the pipeline reject it in real time.
Revert the broken heading

Before adding the CI pipeline, you need a clean codebase to push. The heading in app/page.tsx is still set to the broken text from the last step.

  • In app/page.tsx, find this line:
      <h1 className="text-4xl font-bold mb-4">Oops I Broke It</h1>
  • Replace Oops I Broke It with Quality Gate Pipeline, so the line looks like this:
      <h1 className="text-4xl font-bold mb-4">Quality Gate Pipeline</h1>
  • Save app/page.tsx.
  • Confirm the tests pass again by running this command:
npm test

You should see 2 tests passed. Press q to exit watch mode.

Tests still failing?

Make sure the heading text is exactly Quality Gate Pipeline with no extra spaces or changed capitalization.

Check that you saved app/page.tsx before running the tests.

help me figure out why my tests are still failing after changing the heading back

Create your GitHub Actions workflow

GitHub Actions lets you define automated workflows as YAML files stored right inside your repository. When you push code, GitHub reads these files and runs the steps you define on a cloud server.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github/workflows

You should see an empty directory listed with no errors.

  • Create a new file called ci.yml inside .github/workflows/ and paste the following code into it:
name: CI Pipeline

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Use Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "20.x"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npx vitest run

      - name: Build Docker image
        run: docker build -t nextjs-quality-gate .

What does this workflow do?

  • on: push and pull_request tell GitHub to run this workflow whenever you push to main or open a pull request targeting main.
  • The quality-gate job spins up a fresh Ubuntu server, checks out your code, installs Node.js 20.x, and installs your dependencies.
  • It then runs your linter, unit tests, and Docker build in sequence. If any step fails, the pipeline stops and marks the run as failed.
  • Save .github/workflows/ci.yml.

Seeing YAML errors later?

YAML is sensitive to indentation. Make sure each level is indented with exactly 2 spaces, not tabs.

Double-check that ci.yml is inside .github/workflows/ and not in the project root.

help me fix YAML indentation issues in my GitHub Actions workflow file

✔️ Awesome, I've got everything!

Great. Make sure you saved .github/workflows/ci.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete ci.yml file. Make sure yours matches exactly:

name: CI Pipeline

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Use Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "20.x"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npx vitest run

      - name: Build Docker image
        run: docker build -t nextjs-quality-gate .
Push your code and watch the pipeline run

You have two changes ready to push: the fixed heading and the new CI workflow. Once this lands on main, GitHub will automatically pick up the workflow file and run the pipeline.

  • Commit and push both changes by running these commands:
git add .
git commit -m "add CI pipeline and fix heading"
git push

Before you check the results, what do you expect to see? Your heading is fixed, your tests passed locally, and the linter was clean. Will the pipeline pass or fail?

  • Open your GitHub repository in a browser.
  • Click the Actions tab at the top of the page.

You should see your workflow run with a green checkmark. Every step passed: lint, unit tests, and Docker build.

What is a quality gate?

A quality gate is a set of automated checks that code must pass before it can move forward. Your pipeline runs three gates: linting, testing, and building.

If any gate fails, the pipeline stops. This means broken code gets flagged immediately instead of silently shipping to production.

The pipeline passed because your code is clean. Now put it to the real test.

  • In app/page.tsx, find this line:
      <h1 className="text-4xl font-bold mb-4">Quality Gate Pipeline</h1>
  • Change Quality Gate Pipeline to Oops I Broke It, so the line looks like this:
      <h1 className="text-4xl font-bold mb-4">Oops I Broke It</h1>
  • Save app/page.tsx.
  • Commit and push the broken code by running these commands:
git add .
git commit -m "break heading to test CI pipeline"
git push

What do you think the pipeline will do this time? The heading no longer matches what your test expects.

  • Return to the Actions tab in your GitHub repository.
  • Click on the latest workflow run to see the details.

The run has a red icon. The pipeline caught the broken test and stopped. This is exactly the same broken code that sailed through in Step 4 with zero warnings.

Why did the pipeline stop at unit tests?

The pipeline runs each step in order. Lint passed because the code is syntactically valid. But the unit test step failed because the heading text no longer matches what the test expects.

When a step fails, every step after it is skipped. The Docker build never ran. Broken code was blocked from going any further.

Now fix the heading and prove the pipeline recovers.

  • In app/page.tsx, find this line:
      <h1 className="text-4xl font-bold mb-4">Oops I Broke It</h1>
  • Change Oops I Broke It back to Quality Gate Pipeline, so the line looks like this:
      <h1 className="text-4xl font-bold mb-4">Quality Gate Pipeline</h1>
  • Save app/page.tsx.
  • Commit and push the fix by running these commands:
git add .
git commit -m "fix heading after CI test"
git push

Before you check, do you expect the pipeline to pass or fail this time?

  • Return to the Actions tab in your GitHub repository.

The latest run is green again. Lint passed, tests passed, and the Docker image built successfully. You just built a quality gate.

Pipeline not running at all?

Make sure ci.yml is at the path .github/workflows/ci.yml in your repository, not in the project root.

Check that the file name ends in .yml and not .yaml. Both extensions work, but confirm the file is in the right folder.

help me figure out why my GitHub Actions workflow is not running when I push

Secret mission

Add Playwright End-to-End Testing

Your CI pipeline catches lint and unit test failures, but it has no idea if the page actually looks right in a real browser. In this mission, you'll add Playwright end-to-end tests that launch a headless Chromium browser, navigate your app, and verify what a user actually sees. Then you'll wire those tests into your pipeline so every push gets browser-level validation.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses GitHub Actions' free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The nextjs-quality-gate project folder on your Desktop.
  • The nextjs-quality-gate Docker image on your machine.
  • Playwright Chromium browser binaries installed locally.
  • The nextjs-quality-gate GitHub repository.

Keep everything running

No action needed. Choose this if you are still actively building or planning to extend this project.

  • Your project folder, Docker image, and GitHub repo will stay as they are.
  • Node.js, Docker, and Playwright browsers remain installed on your machine.
  • GitHub Actions only runs when you push, so it costs nothing while idle.

Pause - I'll come back to this later

Free up Docker resources while keeping your files and repository intact.

  • Stop any running Docker containers by pressing Ctrl+C in the terminal where they are running.
  • Remove the Docker image to free up disk space by running:
docker rmi nextjs-quality-gate
  • Remove Playwright browser binaries by running:
npx playwright uninstall --all
  • Your project folder and GitHub repository stay intact, so you can rebuild the image and reinstall browsers when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop any running Docker containers by pressing Ctrl+C in the terminal where they are running.
  • Remove the Docker image by running:
docker rmi nextjs-quality-gate
  • Remove Playwright browser binaries by running:
npx playwright uninstall --all
  • Delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf nextjs-quality-gate
  • On Windows, run:
cd ~\Desktop
rmdir /s /q nextjs-quality-gate
  • Delete the GitHub repository by opening your nextjs-quality-gate repository in your browser.
  • Click Settings in the top menu bar.
  • Scroll down to the Danger Zone section at the bottom of the page.
  • Click Delete this repository and follow the confirmation prompts to finalize the deletion.

Nice Work!

Nice Work!

Nice work! You just built a complete quality gate CI pipeline from scratch.

Every time you push code to GitHub, your pipeline automatically lints it, tests it, and builds a production Docker image. If anything breaks, you know before it reaches main.

You've learned how to:

  • Build a Next.js app with TypeScript and write Vitest unit tests that catch bugs locally before code leaves your machine.
  • Containerize the app with a production-optimized multi-stage Docker build using standalone mode, shrinking the image from ~1 GB to ~110 MB.
  • Experience firsthand what happens when broken code has no safety net, then build a GitHub Actions CI pipeline that automatically runs ESLint, unit tests, and a Docker build on every push.
  • Secret Mission: Add Playwright end-to-end testing to the app and CI pipeline, verifying your app the way a real user interacts with it.

Ready to quiz yourself?