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_deniedwhen pushing to GHCR: the workflow lacks package write access. Ensure thepermissions: packages: writeblock 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
enginesfield warnings in thenpm cilog and pin compatible versions. npm cifails with lockfile mismatch:package-lock.jsonwas not committed. Runnpm installlocally, commit the lockfile, and push again.- Image pushed but
docker pullreturnsdenied: GHCR packages are private by default. Open the package page on GitHub and set visibility to public, ordocker login ghcr.iofirst. - Workflow never triggers: the YAML file is not at
.github/workflows/ci.ymlor has an indentation error. Validate withgh 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