Skip to main content

Build Your First CI Pipeline with GitHub Actions

Build a GitHub Actions CI pipeline that tests, lints and Docker-builds a Node.js app on every pull request, and blocks merges when a check fails.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a GitHub Actions continuous integration (CI) pipeline for a Node.js API. On every pull request and every push to main, the pipeline runs Jest tests on Node.js 22 and 24, lints the code with ESLint, builds a Docker image, and blocks the merge if any check fails. On main it also publishes the image to GitHub Container Registry (GHCR).

What you will build, learn and show

  • Build: a small calculator API with automated tests, a lint config, a Dockerfile and one workflow file at .github/workflows/ci.yml.
  • Learn: how workflows, jobs, steps and runners fit together, how a build matrix tests several Node.js versions at once, how needs orders jobs, how caching speeds up runs, and how branch rules turn a red check into a hard stop.
  • Portfolio deliverable: a public GitHub repository with a green pipeline badge in the README, a downloadable coverage report, and a container image published to GHCR.
  • Before you start: a GitHub account, Git, and Node.js 22 or 24 installed. Docker Desktop is optional (it lets you test the image locally before CI does).

Why teams need CI

Picture a payments team at a company like Razorpay merging dozens of pull requests a day. A reviewer reads the diff, approves it, and nobody notices that it breaks three tests. The next engineer pulls main and loses an hour finding out why their branch fails. CI removes that guessing: a workflow (a YAML file that GitHub runs automatically) executes the same checks on a clean machine for every change, and the pull request shows a red cross before anyone merges.

GitHub Actions is built into GitHub, so there is no server to install. GitHub starts a fresh runner (a temporary virtual machine) for each job. Actions is free for public repositories, which makes this project cost nothing.

How the pipeline flows

The test and lint jobs run in parallel. The build job only starts when both pass, and it only pushes an image when the change lands on main.

flowchart LR A[Push or
pull request]:::slate --> B[Run Tests
Node 22 and 24]:::blue A --> C[Lint Check
ESLint]:::purple B --> D[Build Docker
image]:::orange C --> D D --> E{On main?}:::slate E -->|Yes| F[Push to GHCR]:::green E -->|No| G[Build only]:::soft classDef blue fill:#326CE5,stroke:#1E4FB5,color:#FFFFFF classDef green fill:#10B981,stroke:#047857,color:#FFFFFF classDef orange fill:#F97316,stroke:#C2410C,color:#FFFFFF classDef purple fill:#8B5CF6,stroke:#6D28D9,color:#FFFFFF classDef slate fill:#475569,stroke:#1E293B,color:#FFFFFF classDef soft fill:#F1F5F9,stroke:#94A3B8,color:#1E293B

Milestone 1: Create a Tested Node.js App

This milestone creates a small Express API and a Jest test suite that you will run locally before CI ever sees it.

CI is only as useful as the checks it runs. A pipeline with no tests just proves the code starts, so you write real tests first and make sure they pass on your own machine. That way, when CI fails later, you know the failure is real and not a setup problem.

Bash
mkdir github-actions-demo && cd github-actions-demo
git init -b main
npm init -y
npm install express
npm install --save-dev jest supertest
mkdir src
# Add scripts without opening package.json
npm pkg set main="src/server.js"
npm pkg set scripts.start="node src/server.js"
npm pkg set scripts.test="jest --coverage"
npm pkg set scripts.lint="eslint src"

Create .gitignore so you never commit dependencies or build output:

TEXT
node_modules
coverage
.env

Create src/app.js. The app is exported on its own so tests can import it without opening a network port:

JAVASCRIPT
const express = require('express');
const app = express();
app.use(express.json());
app.post('/add', (req, res) => {
const { a, b } = req.body;
if (typeof a !== 'number' || typeof b !== 'number') {
return res.status(400).json({ error: 'a and b must be numbers' });
}
res.json({ result: a + b });
});
app.post('/multiply', (req, res) => {
const { a, b } = req.body;
if (typeof a !== 'number' || typeof b !== 'number') {
return res.status(400).json({ error: 'a and b must be numbers' });
}
res.json({ result: a * b });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
module.exports = app;

Create src/server.js, the only file that starts a listener:

JAVASCRIPT
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

Create src/app.test.js:

JAVASCRIPT
const request = require('supertest');
const app = require('./app');
describe('Calculator API', () => {
test('adds two numbers', async () => {
const res = await request(app).post('/add').send({ a: 5, b: 3 });
expect(res.status).toBe(200);
expect(res.body.result).toBe(8);
});
test('rejects non-number input for add', async () => {
const res = await request(app).post('/add').send({ a: 'five', b: 3 });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
test('multiplies two numbers', async () => {
const res = await request(app).post('/multiply').send({ a: 4, b: 7 });
expect(res.status).toBe(200);
expect(res.body.result).toBe(28);
});
test('rejects non-number input for multiply', async () => {
const res = await request(app).post('/multiply').send({ a: 4 });
expect(res.status).toBe(400);
});
test('health check returns healthy', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');
});
});

Create jest.config.js. The coverage threshold makes CI fail if someone deletes tests and coverage drops, which is a quality gate you get for free:

JAVASCRIPT
module.exports = {
testEnvironment: 'node',
collectCoverageFrom: ['src/**/*.js', '!src/server.js', '!src/**/*.test.js'],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
},
};
Bash
npm test
# Expected: 5 tests pass and a coverage table shows 100% for app.js
Command What it does
npm pkg set scripts.test="..." Writes a script into package.json from the terminal
npm test Runs Jest with a coverage report
git init -b main Creates the repository with main as the first branch
Tip

Commit package-lock.json. CI uses npm ci, which installs the exact versions in the lock file and fails fast if it is missing or out of sync with package.json.

Common Mistake

Adding node_modules to a commit because there is no .gitignore. It bloats the repository and slows every checkout. Create .gitignore before your first git add.

Milestone 2: Add ESLint Quality Checks

This milestone adds ESLint so the pipeline can reject code with unused variables, loose equality and other common bugs.

A linter reads your code without running it and flags patterns that usually cause bugs. Running it in CI means style and quality rules are enforced for everyone, not left to whoever reviews the pull request. ESLint 10 only reads the flat config format (eslint.config.js), so older tutorials that create .eslintrc.json will not work.

Bash
npm install --save-dev eslint @eslint/js globals

Create eslint.config.js:

JAVASCRIPT
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
js.configs.recommended,
{
files: ['**/*.js'],
languageOptions: {
sourceType: 'commonjs',
globals: { ...globals.node, ...globals.jest },
},
rules: {
'no-unused-vars': 'error',
eqeqeq: 'error',
'no-var': 'error',
'prefer-const': 'error',
},
},
];
Bash
npm run lint
# Expected: no output and exit code 0 (the code is clean)
Command What it does
npm run lint Runs ESLint on everything under src/
npx eslint src --fix Auto-fixes the problems ESLint knows how to fix
Common Mistake

Creating a .eslintrc.json because an older guide said so. ESLint 10 ignores it and reports that no eslint.config.js was found. Use the flat config above.

Milestone 3: Write the Dockerfile

This milestone packages the API into a Docker image so the pipeline can prove the app still builds as a container.

CI that only runs tests can miss a broken Dockerfile until deploy day. Building the image on every pull request catches a missing file or a bad base image immediately. The Dockerfile copies package*.json first so Docker caches the dependency layer and only reinstalls when dependencies change.

Create Dockerfile:

Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]

Create .dockerignore so the build context stays small:

Bash
node_modules
.git
.github
coverage
**/*.test.js

If Docker is installed, test the image locally before CI does:

Bash
docker build -t ci-demo:local .
docker run --rm -d -p 3000:3000 --name ci-demo ci-demo:local
curl http://localhost:3000/health
# Expected: {"status":"healthy"}
docker stop ci-demo
Command What it does
docker build -t ci-demo:local . Builds the image from the Dockerfile in the current folder
docker run --rm -d -p 3000:3000 Runs it in the background and maps port 3000
docker stop ci-demo Stops the container (--rm removes it)
Security

USER node runs the app as a non-root user. If someone exploits the app, they land in an unprivileged account instead of root inside the container.

Common Mistake

Writing COPY . . before npm ci. Every code edit then invalidates the dependency layer and the install reruns on every build.

Milestone 4: Build the CI Workflow

This milestone writes .github/workflows/ci.yml, the file that turns your checks into an automatic pipeline.

A workflow has three levels. Jobs run on separate runners and, by default, in parallel. Steps run in order inside a job. A matrix repeats one job for several inputs, which is how you test Node.js 22 and 24 without copying the YAML. The needs keyword makes the build job wait for test and lint, so you never spend minutes building an image from code that already failed.

Bash
mkdir -p .github/workflows

Create .github/workflows/ci.yml:

YAML
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
# Least privilege: jobs can read the repo and nothing else by default
permissions:
contents: read
# A new push cancels the older run for the same branch
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node-version: [22.x, 24.x]
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test
- name: Upload coverage report
if: matrix.node-version == '24.x'
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage/
retention-days: 7
lint:
name: Lint Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [test, lint]
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build image (no push yet)
uses: docker/build-push-action@v7
with:
context: .
push: false
tags: ci-demo:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Keyword What it does
on Chooses which events start the workflow
matrix Repeats the job once per listed value
needs Makes a job wait for other jobs to succeed
concurrency Cancels a superseded run so you do not waste minutes
permissions Limits what the automatic GITHUB_TOKEN can do
cache: npm Restores downloaded packages between runs
Tip

The coverage upload runs only for Node 24 (if: matrix.node-version == '24.x'). Without that condition both matrix jobs upload an artifact with the same name and the second one fails.

Security

Actions referenced by a version tag such as @v7 can change if the tag is moved. Several popular actions were compromised this way, including tj-actions/changed-files in 2025. Dependabot (Milestone 8) keeps tags current, and pinning to a full commit SHA is the stricter option once you are comfortable.

Common Mistake

Tabs or wrong indentation in YAML. Workflows fail with "Invalid workflow file" and a line number. Use two spaces, never tabs.

Milestone 5: Push to GitHub and Read the Run

This milestone puts the project on GitHub and watches the pipeline run for the first time.

Create an empty repository named github-actions-demo on github.com (no README, no .gitignore), then push:

Bash
git add .
git commit -m "feat: Node.js API with CI pipeline"
git remote add origin https://github.com/YOUR_USERNAME/github-actions-demo.git
git push -u origin main

Open the Actions tab and click the run. You should see four jobs: Run Tests (22.x), Run Tests (24.x), Lint Check, and Build Docker Image. Click any job, then any step, to read its log.

Things worth noticing in the logs:

  • The two test jobs started at the same moment because the matrix runs them in parallel.
  • Build Docker Image stayed grey until test and lint both finished.
  • On the second run, the setup step reports a restored npm cache and the whole run gets faster.
  • The run summary lists coverage-report under Artifacts. Download it and open index.html to see line-by-line coverage.
Where to look What you learn
Actions tab, run summary Which jobs passed and how long each took
A job's step log The exact command output, same as your terminal
Artifacts section Files your workflow saved, such as coverage
Common Mistake

Pushing and seeing no run at all. The file must be at exactly .github/workflows/ci.yml (note the .github folder and the workflows plural) and the push must target a branch listed under on.

Milestone 6: Prove CI Blocks Bad Code

This milestone breaks the code on purpose to confirm the pipeline catches both a failing test and a lint error.

A pipeline you have never seen fail is a pipeline you cannot trust. Always verify the red path, not just the green one.

Bash
git checkout -b test/break-ci

Make two edits by hand:

  • In src/app.test.js, change expect(res.body.result).toBe(8); to toBe(999);.
  • In src/app.js, add const unused = 1; on a new line at the top.
Bash
git add .
git commit -m "test: break tests and lint on purpose"
git push -u origin test/break-ci

Here is what you are about to trigger. Red nodes fail, the grey node is skipped, and the merge button stays locked:

flowchart LR A[Bad commit
on a branch]:::slate --> B[Run Tests
fails]:::red A --> C[Lint Check
fails]:::red B --> D[Build image
skipped]:::soft C --> D D --> E[Merge
blocked]:::red classDef red fill:#EF4444,stroke:#B91C1C,color:#FFFFFF classDef slate fill:#475569,stroke:#1E293B,color:#FFFFFF classDef soft fill:#F1F5F9,stroke:#94A3B8,color:#1E293B

Open a pull request from test/break-ci into main on GitHub (or run gh pr create --fill if you use the GitHub CLI). Watch the checks. Expected result:

  • Run Tests (22.x) and Run Tests (24.x) fail with a Jest diff showing Expected: 999, Received: 8.
  • Lint Check fails, naming the unused variable and its line.
  • Build Docker Image is skipped because needs was not satisfied.

Now fix it the way a team would, by reverting the bad commit:

Bash
git revert --no-edit HEAD
git push
# The pipeline reruns on the new commit and turns green
Tip

Because fail-fast is false, both Node versions run to completion even when one fails. That shows you whether a bug is version-specific.

Common Mistake

Reading only the red cross. Open the failed step and read the log from the first error upward. The first error is usually the cause and everything after it is noise.

Milestone 7: Publish the Image to GHCR

This milestone upgrades the build job so a merge to main publishes a tagged image to GitHub Container Registry, with no secrets to manage.

CI proves the code is good, and the same pipeline should produce the artifact you deploy. GHCR accepts the automatic GITHUB_TOKEN, so you do not create or store any password. The job needs packages: write permission, which you grant to that one job only. The push is skipped on pull requests, so untrusted branches never publish anything.

Replace the whole build job in ci.yml with this version:

YAML
build:
name: Build and Publish Image
runs-on: ubuntu-latest
needs: [test, lint]
timeout-minutes: 15
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
# Image names must be lowercase, repository names may not be
- name: Set lowercase image name
run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

Commit this to a branch, open a pull request, and confirm the job builds without pushing. Merge it, and the run on main pushes sha-<commit> and latest tags. Find the image on your GitHub profile under Packages.

Bash
docker pull ghcr.io/YOUR_USERNAME/github-actions-demo:latest
docker run --rm -p 3000:3000 ghcr.io/YOUR_USERNAME/github-actions-demo:latest
Setting Why it matters
packages: write on this job only The rest of the workflow stays read-only
github.event_name != 'pull_request' Pull requests build but never publish
type=sha tag Every image traces back to an exact commit
Common Mistake

A denied error on push. Check that the job has packages: write, and that the package is not linked to a different repository. A new GHCR package is private by default, so switch it to public in Package settings if you want anyone to pull it.

Milestone 8: Protect main and Make It Portfolio Ready

This milestone makes the checks mandatory, adds a status badge, and automates dependency updates.

Until now, CI warns but does not stop anyone. A branch rule turns a red check into a blocked merge. Open Settings, then Rules, then Rulesets, then New branch ruleset.

  • Name it protect-main, set Enforcement to Active, and target the default branch.
  • Enable Require status checks to pass and add Run Tests (22.x), Run Tests (24.x), Lint Check and Build and Publish Image. Checks only appear in the picker after they have run once.
  • Enable Require a pull request before merging so changes always go through CI.

Add a badge and a short README so visitors see the pipeline status at a glance. Create README.md:

MARKDOWN
# GitHub Actions Demo
![CI](https://github.com/YOUR_USERNAME/github-actions-demo/actions/workflows/ci.yml/badge.svg)
Calculator API with a full CI pipeline: Jest on Node 22 and 24, ESLint,
Docker build, and image publishing to GHCR.
## Run locally
npm ci && npm start

Create .github/dependabot.yml so outdated npm packages and Actions versions arrive as pull requests that CI tests automatically:

YAML
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
Security

The pull_request event gives pull requests from forks a read-only token and no secrets, which is what you want. Never switch to pull_request_target and then check out the pull request's code, because that runs untrusted code with write access.

Common Mistake

Rulesets and branch protection are not enforced on private repositories on the GitHub Free plan. Keep this project public, or the rule will appear to save but not block merges.

Validation & Testing

Run these checks to confirm the project works end to end.

Bash
# 1. Tests and coverage pass locally
npm test
# Expected: 5 passed, coverage 100% for src/app.js
# 2. Lint is clean locally
npm run lint
# Expected: no output, exit code 0
# 3. The image builds and responds
docker build -t ci-demo:local . && docker run --rm -d -p 3000:3000 --name ci-demo ci-demo:local
curl http://localhost:3000/health
# Expected: {"status":"healthy"}
docker stop ci-demo
# 4. The published image runs from the registry
docker pull ghcr.io/YOUR_USERNAME/github-actions-demo:latest
# Expected: pull succeeds

Then confirm on GitHub:

Check Expected result
Actions tab, latest run on main Four green jobs, including both Node versions
Run summary, Artifacts coverage-report downloads and opens
A pull request with a failing test Red cross, merge button blocked
Packages on your profile Image with latest and sha- tags
Second run duration Faster than the first (npm and Docker cache hits)
README Green CI badge renders

When all six rows pass, the project is portfolio ready. A natural next project is containerising a real application, covered in the Docker project, and then deploying it in the Kubernetes project.

Found a mistake, an outdated step or a broken link?