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
needsorders 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.
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.
mkdir github-actions-demo && cd github-actions-demogit init -b mainnpm init -ynpm install expressnpm install --save-dev jest supertestmkdir src # Add scripts without opening package.jsonnpm 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:
node_modulescoverage.envCreate src/app.js. The app is exported on its own so tests can import it without opening a network port:
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:
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:
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:
module.exports = { testEnvironment: 'node', collectCoverageFrom: ['src/**/*.js', '!src/server.js', '!src/**/*.test.js'], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 }, },};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 |
TipCommit
package-lock.json. CI usesnpm ci, which installs the exact versions in the lock file and fails fast if it is missing or out of sync withpackage.json.
Common MistakeAdding
node_modulesto a commit because there is no.gitignore. It bloats the repository and slows every checkout. Create.gitignorebefore your firstgit 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.
npm install --save-dev eslint @eslint/js globalsCreate eslint.config.js:
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', }, },];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 MistakeCreating a
.eslintrc.jsonbecause an older guide said so. ESLint 10 ignores it and reports that noeslint.config.jswas 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:
FROM node:24-alpineWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devCOPY src/ ./src/ENV NODE_ENV=productionEXPOSE 3000USER nodeCMD ["node", "src/server.js"]Create .dockerignore so the build context stays small:
node_modules.git.githubcoverage**/*.test.jsIf Docker is installed, test the image locally before CI does:
docker build -t ci-demo:local .docker run --rm -d -p 3000:3000 --name ci-demo ci-demo:localcurl 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 noderuns 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 MistakeWriting
COPY . .beforenpm 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.
mkdir -p .github/workflowsCreate .github/workflows/ci.yml:
name: CI Pipeline on: push: branches: [main] pull_request: branches: [main] # Least privilege: jobs can read the repo and nothing else by defaultpermissions: contents: read # A new push cancels the older run for the same branchconcurrency: 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 |
TipThe 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.
SecurityActions referenced by a version tag such as
@v7can change if the tag is moved. Several popular actions were compromised this way, includingtj-actions/changed-filesin 2025. Dependabot (Milestone 8) keeps tags current, and pinning to a full commit SHA is the stricter option once you are comfortable.
Common MistakeTabs 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:
git add .git commit -m "feat: Node.js API with CI pipeline"git remote add origin https://github.com/YOUR_USERNAME/github-actions-demo.gitgit push -u origin mainOpen 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 Imagestayed 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-reportunder Artifacts. Download it and openindex.htmlto 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 MistakePushing and seeing no run at all. The file must be at exactly
.github/workflows/ci.yml(note the.githubfolder and theworkflowsplural) and the push must target a branch listed underon.
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.
git checkout -b test/break-ciMake two edits by hand:
- In
src/app.test.js, changeexpect(res.body.result).toBe(8);totoBe(999);. - In
src/app.js, addconst unused = 1;on a new line at the top.
git add .git commit -m "test: break tests and lint on purpose"git push -u origin test/break-ciHere is what you are about to trigger. Red nodes fail, the grey node is skipped, and the merge button stays locked:
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)andRun Tests (24.x)fail with a Jest diff showingExpected: 999, Received: 8.Lint Checkfails, naming the unused variable and its line.Build Docker Imageis skipped becauseneedswas not satisfied.
Now fix it the way a team would, by reverting the bad commit:
git revert --no-edit HEADgit push# The pipeline reruns on the new commit and turns greenTipBecause
fail-fastisfalse, both Node versions run to completion even when one fails. That shows you whether a bug is version-specific.
Common MistakeReading 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:
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=maxCommit 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.
docker pull ghcr.io/YOUR_USERNAME/github-actions-demo:latestdocker 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 MistakeA
deniederror on push. Check that the job haspackages: 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 CheckandBuild 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:
# GitHub Actions Demo  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 startCreate .github/dependabot.yml so outdated npm packages and Actions versions arrive as pull requests that CI tests automatically:
version: 2updates: - package-ecosystem: npm directory: / schedule: interval: weekly - package-ecosystem: github-actions directory: / schedule: interval: weeklySecurityThe
pull_requestevent gives pull requests from forks a read-only token and no secrets, which is what you want. Never switch topull_request_targetand then check out the pull request's code, because that runs untrusted code with write access.
Common MistakeRulesets 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.
# 1. Tests and coverage pass locallynpm test# Expected: 5 passed, coverage 100% for src/app.js # 2. Lint is clean locallynpm run lint# Expected: no output, exit code 0 # 3. The image builds and respondsdocker build -t ci-demo:local . && docker run --rm -d -p 3000:3000 --name ci-demo ci-demo:localcurl http://localhost:3000/health# Expected: {"status":"healthy"}docker stop ci-demo # 4. The published image runs from the registrydocker pull ghcr.io/YOUR_USERNAME/github-actions-demo:latest# Expected: pull succeedsThen 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.