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
- Concepts: CI/CD and GitHub Actions
- Hands-on: single workflow template
- Advanced: reusable workflows, matrix, cache
- Performance: cache and parallelism
- Real-world scenarios
- Troubleshooting
- 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
| Technique | Effect | Notes |
|---|---|---|
npm ci + lockfile | Reproducible installs | CI default |
setup-node cache | Faster installs | Use everywhere |
| Docker BuildKit cache | Faster image builds | Registry or inline cache |
| Parallel jobs | Faster PR feedback | Watch 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: productionwith Required reviewers gates CD. - Tag releases:
on: push: tags: ['v*']to pushv1.2.3images. - Migrations: run
npm run migratein a deploy job with documented lock timeouts and rollback.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| GHCR push denied | Missing token scope | permissions: packages: write on the job |
npm ci fails | Lockfile drift | Regenerate and commit lockfile locally |
| Failures only on one Node version | Version mismatch | Pin engines and setup-node |
| Slow Docker builds | No cache | BuildKit cache or --cache-from |
| Deploy runs old code | Pulling latest only | Deploy 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.