Most teams start with GitHub Actions because it's already there — your code is on GitHub, so the CI runner is a checkbox away. But a real production pipeline is more than running npm test on every push. It needs caching, Docker builds, multi-stage environments, artifact management, and deployment that doesn't wake you up at 3 AM.
I've built and broken enough pipelines to know what works. Here's a production-grade GitHub Actions workflow — explained piece by piece so you understand why each part exists.
1. The Workflow Skeleton
Every GitHub Actions workflow lives in .github/workflows/. We'll build a single file called ci-cd.yml that handles test, build, and deploy for a Node.js + Docker application.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
The on.push trigger runs on main-branch commits. The pull_request trigger runs on PRs targeting main — so you catch failures before merge. The env block defines shared variables so you don't repeat yourself in every step.
2. Job 1: Lint & Test (Fast Feedback)
Your first job should finish in under 2 minutes. Speed matters because this is what runs on every PR commit — slow tests kill developer productivity.
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
Key details that matter:
-
npm ci— notnpm install.ciuses the lockfile exactly, fails if lockfile ≠ package.json, and is 2-3x faster. Never usenpm installin CI. -
cache: 'npm'— built intosetup-node. Caches~/.npmbased onpackage-lock.json. Cuts install time from 45s to 8s. -
upload-artifactwithif: always()— coverage reports are useful even when tests fail. You can browse them in the Actions UI. - Separation of concerns — this job has nothing to do with Docker or deployment. It fails fast and tells the developer "your code is broken" before anything expensive runs.
3. Job 2: Docker Build & Scan
The second job builds the Docker image. Crucially, it waits for lint-test to pass. GitHub Actions uses needs for this.
docker-build:
needs: lint-test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Three things here that beginners get wrong:
-
Two tags —
latestlets you always pull the newest. Theshatag lets you deploy a specific version and roll back. Without the SHA tag, you can't tell which commit is running in production. -
cache-from/cache-to: type=gha— this is GitHub Actions' magic cache for Docker layers. Without it, every build starts from scratch. With it, only changed layers rebuild. A 3-minute build drops to 40 seconds on the second run. -
permissions: packages: write— the default GITHUB_TOKEN can't push packages. You must explicitly grant write access to the GitHub Container Registry.
4. Job 3: Security Scan
Don't deploy vulnerable images. Add a scan step between build and deploy:
security-scan:
needs: docker-build
runs-on: ubuntu-latest
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
category: 'trivy'
Trivy scans your image against known CVE databases. If someone added a vulnerable dependency, you'll see it in GitHub's Security tab before it ever hits production. I've caught 4 critical CVEs this way — including an undici HTTP request smuggling vulnerability that wasn't flagged by npm audit.
5. Job 4: Deploy
Deployment is the most environment-specific part. I'll show a generic SSH-based deploy to a VPS, which applies to most small-to-mid-size setups:
deploy:
needs: [security-scan]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/my-app
docker compose pull
docker compose up -d --remove-orphans
docker system prune -af --filter "until=24h"
This runs docker compose pull on your server (it fetches the updated image you just pushed), recreates containers, and cleans up old images that are more than 24 hours old. The --remove-orphans flag removes containers defined in the compose file that are no longer running — prevents stale container drift.
The deploy job only runs on the main branch (if: github.ref == 'refs/heads/main'). PR builds stop at security-scan. This is deliberate — you want to know the image is safe before you deploy, but the actual deploy only happens when code merges.
6. Environment Protection Rules
Don't deploy to production just because someone pushed to main. Set up environment protection in your repo settings:
- name: Deploy to Production
uses: appleboy/ssh-action@v1.0.3
with:
# ...
environment: production
Then in GitHub → Settings → Environments → production, require:
- Required reviewers — a senior dev must approve the deploy
- Wait timer — 5 minutes. Gives you a window to cancel if the pipeline just ran on the wrong branch
-
Deployment branches — only
maincan deploy to production
This single setting has saved me from deploying broken code more times than I'd like to admit. The 5-minute wait is surprisingly effective — it forces you to sit and watch the first few seconds of deployment logs.
7. Caching: The Single Biggest Speed Win
A pipeline without caching is a slow pipeline. Beyond Docker layer caching, use GitHub's generic cache action for everything else:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
.next/cache
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
This caches npm dependencies AND the Next.js (or similar) build cache. Combined with Docker layer caching, a full pipeline that took 8 minutes now runs in under 3. Your team will stop complaining about slow CI.
8. Matrix Builds (Bonus)
If your app supports multiple runtimes, use a matrix strategy to test them all in parallel:
strategy:
matrix:
node-version: [18, 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
Three parallel runners, each testing a different Node version. They all run simultaneously and the needs condition for downstream jobs only waits for this job to succeed on all matrix entries. If Node 22 has a regression, you'll know before your users do.
Putting It All Together
The final workflow has four sequential stages:
lint-test → docker-build → security-scan → deploy
Each stage gates the next. If linting fails, nothing builds. If the image has critical CVEs, nothing deploys. If the deploy fails, the previous version keeps running because docker compose up only restarts containers when the image actually changes.
The total file is about 90 lines. Copy it into .github/workflows/ci-cd.yml, replace the deploy script with whatever your server needs, and you have a production CI/CD pipeline that handles caching, security scanning, and safe deployment.
One final thing: test your pipeline on a branch before pushing to main. Every time I skip this, I end up fixing YAML indentation via 20 commits to a broken main. Push your workflow to a feature branch first, open a PR, and watch the pipeline run before you merge.
Top comments (0)