intermediate12 min readPart 1 of 1

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.

TechLogHub
·

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

  • A CI workflow that runs on every pull request and push: install, lint, test across two Node versions, with dependency caching.
  • A CD workflow that runs when main changes: build a Docker image, tag it with the commit SHA, push it to ghcr.io, then deploy to production after a reviewer approves.
  • Branch protection so nothing reaches main unless CI is green.
  • 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 test

    What 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:

  • Require a pull request before merging.
  • Require status checks to pass, and select both test jobs.
  • Optionally require branches to be up to date before merging.
  • 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.
  • Tagging by commit SHA (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:

  • Add required reviewers. The deploy job will pause until one of them clicks Approve, which is the difference between continuous delivery (a human presses the button) and continuous deployment (no human).
  • Add the secrets DEPLOY_HOST, DEPLOY_USER and DEPLOY_SSH_KEY to the environment rather than the repository, so only jobs that target production can read them.
  • Optionally restrict the environment to the 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

  • Using npm install in CI. It can silently change the lockfile. Use npm ci.
  • Granting broad token permissions. Without a permissions block, the token may get more access than the job needs. Declare it per workflow.
  • Putting production secrets at repository level. Any workflow on any branch could read them. Scope them to the environment.
  • Deploying latest. You lose the link between what's running and which commit it came from, and rollbacks become guesswork.
  • Floating third-party actions on a branch. Pin actions to a major version at minimum; for third-party actions in sensitive workflows, pin to a full commit SHA.
  • No branch protection. A pipeline nobody is forced to pass is decoration.
  • Where to go next

  • Add a services: block to run Postgres or Redis for integration tests inside the CI job.
  • Add a staging environment that deploys automatically, with production still behind approval.
  • Compare GitHub Actions vs GitLab CI if you're choosing a platform.
  • Keep the Docker cheat sheet and Git cheat sheet open while you work.
  • github actionsci/cddockerdevopsdeployment

    Keep the references coming

    One email a week: new and trending developer tools, fresh comparisons, and what shipped. Unsubscribe in one click.