Build a CI/CD Pipeline with GitHub Actions: Test, Docker, Deploy
Step-by-step GitHub Actions CI/CD pipeline for a Node.js app: lint and test on pull requests, build and push a Docker image to GHCR, deploy with manual approval.
Prerequisites
- •A GitHub repository with a Node.js app
- •Basic Git and Docker knowledge
- •A Linux server with Docker for the deploy step (optional)
What You'll Learn
- Write a CI workflow with a Node version matrix and dependency caching
- Protect main so only passing pull requests merge
- Build and push a SHA-tagged Docker image to GitHub Container Registry
- Deploy behind a manual approval using GitHub environments and scoped secrets
A CI/CD pipeline turns "it works on my machine" into "it works, and it's live" without anyone running commands by hand. This tutorial builds a complete pipeline in GitHub Actions for a Node.js app: lint and test on every pull request, build a Docker image on every merge to main, push it to GitHub Container Registry, and deploy behind a manual approval. Every YAML file below is complete and can be copied as-is.
What you will build
If CI/CD itself is new to you, read what a CI/CD pipeline is first. The short version: CI (continuous integration) checks every change automatically; CD (continuous delivery or deployment) ships the checked change.
How GitHub Actions is organised
A workflow is a YAML file in .github/workflows/. It is triggered by events (push, pull_request, workflow_dispatch, a schedule). A workflow contains jobs, which run in parallel on fresh virtual machines called runners unless you chain them with needs. Each job is a list of steps: either a shell command (run) or a reusable action (uses), such as actions/checkout.
Step 1: the CI workflow
Create .github/workflows/ci.yml:
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm testWhat each part does:
on runs the workflow for every pull request and for pushes to main.concurrency cancels an older run on the same branch when you push again, which saves runner minutes.strategy.matrix runs the job twice, once per Node version, in parallel.cache: npm caches the npm download cache keyed on your lockfile, so repeat installs are much faster.npm ci installs exactly what package-lock.json says and fails if it is out of sync with package.json. Never use npm install in CI.Push a branch and open a pull request: you will see two "test" checks appear on it.
Step 2: block merges unless CI passes
In the repository, open Settings → Rules → Rulesets (or the older Branches → Branch protection rules) and add a rule for main:
test jobs.This is what makes CI meaningful. Without it, a red check is only a suggestion.
Step 3: a Dockerfile to ship
The CD workflow builds an image, so the app needs a Dockerfile. A minimal multi-stage one:
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]Adjust dist/server.js and the port to match your app. The two stages keep build tools and dev dependencies out of the final image.
Step 4: the CD workflow
Create .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: type=sha,format=long
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ needs.build.outputs.image }}
docker stop app || true
docker rm app || true
docker run -d --name app --restart unless-stopped -p 3000:3000 ${{ needs.build.outputs.image }}The important details:
permissions gives the built-in GITHUB_TOKEN write access to packages and nothing more. Always grant the minimum.type=sha,format=long) means every image maps to exactly one commit. Rolling back is redeploying an older tag. Avoid deploying latest.cache-from/cache-to: type=gha stores Docker layer cache in GitHub's cache, so unchanged layers aren't rebuilt.needs: build makes deploy wait for build and lets it read the build job's outputs.environment: production ties the job to a GitHub environment, which is where approvals and production secrets live (next step).The SSH deploy is the simplest target and works for any VPS running Docker. For Kubernetes, replace the deploy step with kubectl set image or a Helm upgrade; for a platform like Fly.io or Render, use their CLI or deploy hook. The build half of the workflow stays the same.
Step 5: secrets and a manual approval gate
In Settings → Environments, create an environment named production:
DEPLOY_HOST, DEPLOY_USER and DEPLOY_SSH_KEY to the environment rather than the repository, so only jobs that target production can read them.main branch.Generate a dedicated SSH key pair for deploys, put the public key in the server's authorized_keys for a user that can run Docker, and paste the private key into DEPLOY_SSH_KEY.
Step 6: watch it run
Merge a pull request. In the Actions tab you'll see Deploy start: build pushes ghcr.io//:sha-, then deploy waits with a "Review deployments" button. Approve it and the container is replaced on the server. The image also appears under the repository's Packages.
Common mistakes
npm install in CI. It can silently change the lockfile. Use npm ci.permissions block, the token may get more access than the job needs. Declare it per workflow.latest. You lose the link between what's running and which commit it came from, and rollbacks become guesswork.Where to go next
services: block to run Postgres or Redis for integration tests inside the CI job.
