Skip to content

beginner ~60 min updated 2026-06-01

CI/CD with GitHub Actions

Build a complete CI/CD pipeline with GitHub Actions: run tests on every push, build a Docker image, and publish it to GitHub Container Registry using a matrix build and caching.

Objective

Create a GitHub Actions workflow that tests a Node.js app on every push and publishes a Docker image to GitHub Container Registry (GHCR) on merges to main. You will use jobs, a Node version matrix, dependency caching, and the built-in GITHUB_TOKEN for registry auth.

Prerequisites

  • A free GitHub account
  • Git installed and configured with your GitHub credentials
  • Node.js 20 or newer installed locally
  • Docker installed for local verification of the image

Architecture

A push to GitHub triggers the workflow. The test job runs the unit tests across two Node versions in parallel. If tests pass and the ref is main, the build-and-push job builds a Docker image and pushes it to GHCR, authenticated with the ephemeral GITHUB_TOKEN — no personal access token needed.

 git push
    |
    v
+---------------------- GitHub Actions ----------------------+
| job: test (matrix: node 20, 22)                            |
|   checkout -> setup-node (cache) -> npm ci -> npm test     |
|                  |                                         |
|                  v  (needs: test, only on main)            |
| job: build-and-push                                        |
|   docker/login-action -> docker/build-push-action          |
+------------------------------|-----------------------------+
                               v
                     ghcr.io/USER/actions-lab:latest

Steps

1. Create the sample application

mkdir actions-lab && cd actions-lab
git init -b main
npm init -y
npm install --save-dev jest

Create sum.js:

function sum(a, b) {
  return a + b;
}
module.exports = sum;

Create sum.test.js:

const sum = require('./sum');
test('adds 2 + 3 to equal 5', () => {
  expect(sum(2, 3)).toBe(5);
});

Set the test script in package.json:

npm pkg set scripts.test="jest"
npm test

2. Add a Dockerfile

# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "-e", "console.log(require('./sum')(2,3))"]

3. Create the workflow file

mkdir -p .github/workflows
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  packages: write

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

4. Push to GitHub and trigger the pipeline

gh repo create actions-lab --public --source=. --push
# or manually:
# git remote add origin https://github.com/YOUR_USER/actions-lab.git
git add -A
git commit -m "Add CI/CD pipeline"
git push -u origin main

5. Watch the run

gh run watch
gh run list --limit 3

6. Pull and run the published image

echo $GH_TOKEN | docker login ghcr.io -u YOUR_USER --password-stdin
docker pull ghcr.io/YOUR_USER/actions-lab:latest
docker run --rm ghcr.io/YOUR_USER/actions-lab:latest

Expected output

$ gh run list --limit 3
STATUS  TITLE                 WORKFLOW  BRANCH  EVENT  ID
✓       Add CI/CD pipeline    CI        main    push   9876543210

$ npm test
PASS  ./sum.test.js
  ✓ adds 2 + 3 to equal 5 (2 ms)
Tests:       1 passed, 1 total

$ docker run --rm ghcr.io/YOUR_USER/actions-lab:latest
5

Troubleshooting

  • denied: permission_denied when pushing to GHCR: the workflow lacks package write access. Ensure the permissions: packages: write block is present, and that the repository setting Actions → Workflow permissions allows write.
  • Matrix job fails only on one Node version: a dependency requires a newer engine. Check the engines field warnings in the npm ci log and pin compatible versions.
  • npm ci fails with lockfile mismatch: package-lock.json was not committed. Run npm install locally, commit the lockfile, and push again.
  • Image pushed but docker pull returns denied: GHCR packages are private by default. Open the package page on GitHub and set visibility to public, or docker login ghcr.io first.
  • Workflow never triggers: the YAML file is not at .github/workflows/ci.yml or has an indentation error. Validate with gh workflow list.

Cleanup

gh repo delete YOUR_USER/actions-lab --yes
docker rmi ghcr.io/YOUR_USER/actions-lab:latest
cd .. && rm -rf actions-lab