GitHub Actions CI/CD Tutorial for Node.js | Test, Build, Docker & Deploy

GitHub Actions CI/CD Tutorial for Node.js | Test, Build, Docker & Deploy

이 글의 핵심

One workflow YAML: install with npm ci, run tests, build production images, push to a registry, and deploy—patterns that reduce human error and ship faster.

Introduction

GitHub Actions reacts to repository events (push, PR, schedule) to automate test, build, and deploy. Node.js services often need install → lint → unit tests → build → Docker image → registry push → runtime deploy; wiring that in one pipeline cuts human error and release time.

Production quality also depends on branch protection, secrets, cache strategy, and rollback. This article focuses on moving “works locally” into a reproducible workflow YAML.

What you will learn

  • How to chain test → build → image → deploy in one flow
  • actions/cache, multi-stage Docker builds, and per-environment jobs
  • Common failures (permissions, tags, registry auth) and debugging tips

Table of contents

  1. Concepts: CI/CD and GitHub Actions
  2. Hands-on: single workflow template
  3. Advanced: reusable workflows, matrix, cache
  4. Performance: cache and parallelism
  5. Real-world scenarios
  6. Troubleshooting
  7. Conclusion

Concepts: CI/CD and GitHub Actions

Terms

  • CI (Continuous Integration): On every merge, automatically install, build, and test to surface integration issues early.
  • CD (Continuous Delivery/Deployment): Promote validated artifacts to staging/production automatically or after approval.
  • Workflow: YAML describing event → job → step graphs.
  • Runner: GitHub-hosted (ubuntu-latest) or self-hosted machines executing jobs.

Why Node.js needs it

The ecosystem is sensitive to lockfiles, Node versions, and native addons. CI must use the same package-lock.json and Node version as production. Docker further pins OS and dependencies.


Hands-on: single workflow template

Example: test on PR, on push to main build/push image and deploy (SSH sketch). Save as .github/workflows/ci-cd.yml.

# .github/workflows/ci-cd.yml
name: CI/CD Node.js

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch: {}

env:
  NODE_VERSION: '22'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    name: Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint --if-present

      - name: Unit tests
        run: npm test --if-present

      - name: Build (if applicable)
        run: npm run build --if-present

  build-and-push:
    name: Docker Build & Push
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  deploy:
    name: Deploy (example SSH)
    needs: build-and-push
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            cd /opt/app && docker compose up -d --no-deps api

Behavior

  • test runs on PRs and pushes.
  • build-and-push runs only on main pushes.
  • deploy is illustrative—swap for kubectl, Fly.io, Railway, etc.

Minimal package.json scripts

{
  "scripts": {
    "lint": "eslint .",
    "test": "node --test",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Multi-stage Dockerfile

# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Advanced: reusable workflows, matrix, cache

Reusable workflow

# .github/workflows/reusable-test.yml
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci && npm test

Cross-platform matrix

strategy:
  fail-fast: false
  matrix:
    node: [20, 22]
    os: [ubuntu-latest, windows-latest]

npm cache

actions/setup-node with cache: npm is enough for most repos; monorepos may set cache-dependency-path.


Performance: cache and parallelism

TechniqueEffectNotes
npm ci + lockfileReproducible installsCI default
setup-node cacheFaster installsUse everywhere
Docker BuildKit cacheFaster image buildsRegistry or inline cache
Parallel jobsFaster PR feedbackWatch minute quotas on paid plans

Trade-off: Large matrices increase queue time and billing—often widen coverage on release branches only.


Real-world scenarios

  • Staging auto, production manual: environment: production with Required reviewers gates CD.
  • Tag releases: on: push: tags: ['v*'] to push v1.2.3 images.
  • Migrations: run npm run migrate in a deploy job with documented lock timeouts and rollback.

Troubleshooting

SymptomCauseFix
GHCR push deniedMissing token scopepermissions: packages: write on the job
npm ci failsLockfile driftRegenerate and commit lockfile locally
Failures only on one Node versionVersion mismatchPin engines and setup-node
Slow Docker buildsNo cacheBuildKit cache or --cache-from
Deploy runs old codePulling latest onlyDeploy by SHA or digest

Tip: Re-run failed jobs with debug logging; try act to reproduce workflows locally.


Conclusion

  • One pipeline for test → build → image → deploy removes repetitive work and mistakes.
  • npm ci, pinned Node, and multi-stage Docker are a solid baseline.
  • Next: align local and staging with Docker Compose for Node.js and read Node.js deployment for operations.