Best CI/CD Pipeline Examples for Node.js Apps
Best CI/CD Pipeline Examples for Node.js Apps
Node.js deployments fail more often due to inconsistent build processes than actual bugs. A developer's code works perfectly on localhost, passes peer review, then crashes in production because the build step used a different Node version, npm installed slightly different dependency versions, or environment variables weren't loaded correctly. These aren't edge cases—they're the primary reason teams implement CI/CD pipelines.
This article walks through production-ready CI/CD pipeline configurations specifically designed for Node.js applications. You'll see working examples for GitHub Actions, GitLab CI, CircleCI, and Jenkins, each handling the unique challenges of Node.js: dependency caching strategies that actually save time, test parallelization for Jest and Mocha suites, proper environment variable management across staging and production, and Docker builds that don't balloon to 1GB.
These aren't minimal examples that work in tutorials but fail under real constraints. Each pipeline handles branch-based deployments, automatic rollback triggers, and the specific integration points Node.js apps need: database migrations with Prisma or TypeORM, Redis cache warming, and CDN invalidation after static asset updates.
Why Node.js CI/CD Differs from Other Languages
Node.js applications introduce specific challenges that generic CI/CD tutorials gloss over. The npm ecosystem's approach to dependency resolution means identical package.json files can produce different node_modules trees depending on installation order and timing. Package-lock.json solves this in theory, but only if every developer and CI runner uses the exact same npm version.
The breaking point surfaces when you're running ten concurrent builds. Five use Node 18.16.0, three use 18.16.1, and two accidentally grabbed 20.1.0 because a developer updated their local Node and the CI config references "latest" instead of a pinned version. Your test suite passes in nine builds and fails in one with cryptic errors about missing native bindings—because one dependency recompiled native modules against a different V8 version.
Then there's the build artifact challenge. Node.js applications don't compile to a single binary. Your "build" is the source code plus node_modules plus any transpiled output if you're using TypeScript. That's 50,000+ files for a typical application. Copying those files between CI stages, uploading them as artifacts, or packaging them into Docker images becomes a performance bottleneck that kills your deployment speed.
The npm vs pnpm vs Yarn Decision
Package manager choice directly impacts CI performance. npm installs dependencies serially and stores them in a nested node_modules structure. pnpm uses hard links to share packages between projects and installs in parallel, cutting typical install times by 40-60%. Yarn maintains its own cache and supports workspaces more elegantly than npm.
The performance difference compounds at scale. A typical Next.js application with 200 dependencies takes npm about 90 seconds to install from scratch. pnpm completes the same operation in 35 seconds. Over 50 builds per day, that's 45 minutes saved—enough to impact developer feedback loops and deployment frequency.
But switching package managers isn't free. If your team uses npm locally, introducing pnpm in CI creates environment drift. Different resolution algorithms can expose dependency conflicts that npm silently resolved differently. The pragmatic approach: if you're starting fresh or already using pnpm/Yarn in development, carry that into CI. If your team is standardized on npm, the migration cost likely exceeds the time savings unless you're running hundreds of builds daily.
GitHub Actions: Complete Node.js Pipeline
GitHub Actions integrates directly with your repository, eliminating the need for webhook configuration and third-party service authentication. For Node.js projects, the primary optimization target is dependency caching—done correctly, this transforms a 2-minute build into a 30-second build on cache hits.
Production-Ready Configuration
name: Node.js CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test -- --coverage
env:
NODE_ENV: test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
env:
NODE_ENV: production
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Deploy to production
run: |
npm install -g pm2
pm2 deploy ecosystem.config.js production
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
This pipeline runs tests against multiple Node versions simultaneously using matrix strategy, ensuring your application works across the Node versions your users might run. The npm ci command (not npm install) guarantees reproducible builds by strictly respecting package-lock.json and failing if versions don't match.
Advanced Caching Strategy
The built-in cache feature in setup-node@v4 handles basic npm caching, but you can achieve faster builds with explicit cache management:
- name: Cache node modules
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
This caches both npm's global cache (~/.npm) and the installed node_modules. The cache key includes the hash of package-lock.json, so any dependency change invalidates the cache. The restore-keys fallback allows partial cache hits when only some dependencies changed—npm then updates only the changed packages instead of reinstalling everything.
GitLab CI: Enterprise Node.js Pipeline
GitLab CI excels in enterprise environments where you need fine-grained control over pipeline stages, manual approval gates, and integration with private Docker registries. For Node.js applications, GitLab's built-in container registry eliminates the need to push Docker images to external services.
Multi-Stage Pipeline with Docker
image: node:20-alpine
stages:
- install
- test
- build
- deploy
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .npm/
install_dependencies:
stage: install
script:
- npm ci --cache .npm --prefer-offline
artifacts:
paths:
- node_modules/
expire_in: 1 hour
test_unit:
stage: test
script:
- npm run test:unit
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
test_integration:
stage: test
services:
- postgres:15-alpine
- redis:7-alpine
variables:
DATABASE_URL: "postgresql://test:test@postgres:5432/test_db"
REDIS_URL: "redis://redis:6379"
script:
- npm run test:integration
only:
- main
- merge_requests
build_docker:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
deploy_staging:
stage: deploy
script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan $STAGING_HOST >> ~/.ssh/known_hosts
- ssh $STAGING_USER@$STAGING_HOST "
docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA &&
docker stop nodejs-app || true &&
docker rm nodejs-app || true &&
docker run -d
--name nodejs-app
-p 3000:3000
-e NODE_ENV=staging
-e DATABASE_URL=$STAGING_DATABASE_URL
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
"
environment:
name: staging
url: https://staging.example.com
only:
- main
deploy_production:
stage: deploy
script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- ssh $PRODUCTION_USER@$PRODUCTION_HOST "
docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA &&
docker-compose -f /opt/app/docker-compose.prod.yml up -d
"
environment:
name: production
url: https://example.com
when: manual
only:
- main
This pipeline separates dependency installation into its own stage, allowing subsequent stages to reuse node_modules via artifacts. The integration tests run against real database services (PostgreSQL and Redis) that GitLab spins up automatically via the services directive—no mocking required, which catches integration issues that unit tests miss.
Why Separate Unit and Integration Tests
Unit tests run in every pipeline, including feature branches and merge requests. They're fast (under 30 seconds for most codebases) and catch the majority of regressions. Integration tests require database setup, seed data, and multiple service connections—they take 3-5 minutes even with optimization.
Running integration tests only on main and merge requests maintains fast feedback loops for feature branch commits while still catching integration issues before they reach production. You save 4-5 minutes per push, which compounds quickly across a team of ten developers making 50 commits per day.
CircleCI: Parallelized Test Execution
CircleCI's primary advantage for Node.js projects is built-in test parallelization. For large test suites (1,000+ tests), CircleCI can automatically split tests across multiple containers, reducing total execution time from 10 minutes to 2-3 minutes.
Configuration with Test Splitting
version: 2.1
orbs:
node: circleci/[email protected]
executors:
node-executor:
docker:
- image: cimg/node:20.11
resource_class: medium+
jobs:
install-and-test:
executor: node-executor
parallelism: 4
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ checksum "package-lock.json" }}
- v1-deps-
- run:
name: Install dependencies
command: npm ci
- save_cache:
key: v1-deps-{{ checksum "package-lock.json" }}
paths:
- node_modules
- ~/.npm
- run:
name: Run tests
command: |
TESTFILES=$(circleci tests glob "src/**/*.test.js" | circleci tests split --split-by=timings)
npm test -- $TESTFILES
- store_test_results:
path: test-results
- store_artifacts:
path: coverage
build-and-deploy:
executor: node-executor
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ checksum "package-lock.json" }}
- run:
name: Build application
command: npm run build
- run:
name: Deploy to AWS
command: |
npm install -g aws-cli
aws s3 sync dist/ s3://$AWS_S3_BUCKET/ --delete
aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_ID --paths "/*"
workflows:
version: 2
build-test-deploy:
jobs:
- install-and-test
- build-and-deploy:
requires:
- install-and-test
filters:
branches:
only: main
The parallelism: 4 directive tells CircleCI to spin up four containers. The test splitting logic divides test files across containers based on historical timing data, ensuring each container finishes around the same time. This is more sophisticated than simple file splitting—CircleCI knows that some test files take longer than others and distributes accordingly.
Cost-Performance Tradeoffs
Running four parallel containers cuts test time from 8 minutes to 2 minutes, but you're paying for four containers instead of one. CircleCI charges by container-minute, so you're using 8 container-minutes either way—but the 6-minute reduction in wall-clock time means faster deployment cycles and quicker feedback for developers.
| Configuration | Wall Time | Container Minutes | Monthly Cost (100 builds) |
|---|---|---|---|
| 1 container | 8 minutes | 8 | $48 |
| 2 containers | 4 minutes | 8 | $48 |
| 4 containers | 2 minutes | 8 | $48 |
| 8 containers | 1 minute | 8 | $48 |
The sweet spot is usually 4-6 containers. Beyond that, the overhead of container startup and test coordination erodes the parallelization benefits. Going from 4 to 8 containers might only cut 30 seconds from wall time because CircleCI needs time to distribute work and collect results.
Jenkins: Self-Hosted Node.js Pipeline
Jenkins makes sense when you need complete control over the build environment, have security requirements that prevent using cloud CI, or already have Jenkins infrastructure. The tradeoff is maintenance overhead—you're responsible for keeping Jenkins updated, managing plugins, and ensuring build agents have correct Node versions.
Jenkinsfile with Docker Agents
pipeline {
agent none
environment {
NODE_VERSION = '20.11.0'
NPM_CONFIG_CACHE = "${WORKSPACE}/.npm"
}
stages {
stage('Setup') {
agent {
docker {
image "node:${NODE_VERSION}-alpine"
args '-v /var/run/docker.sock:/var/run/docker.sock'
}
}
steps {
sh 'node --version'
sh 'npm --version'
}
}
stage('Install Dependencies') {
agent {
docker {
image "node:${NODE_VERSION}-alpine"
}
}
steps {
sh 'npm ci --prefer-offline --no-audit'
stash includes: 'node_modules/**', name: 'node_modules'
}
}
stage('Parallel Testing') {
parallel {
stage('Unit Tests') {
agent {
docker {
image "node:${NODE_VERSION}-alpine"
}
}
steps {
unstash 'node_modules'
sh 'npm run test:unit'
junit 'test-results/unit/*.xml'
}
}
stage('Integration Tests') {
agent {
docker {
image "node:${NODE_VERSION}-alpine"
}
}
steps {
unstash 'node_modules'
sh '''
docker-compose up -d postgres redis
npm run test:integration
docker-compose down
'''
junit 'test-results/integration/*.xml'
}
}
stage('Lint') {
agent {
docker {
image "node:${NODE_VERSION}-alpine"
}
}
steps {
unstash 'node_modules'
sh 'npm run lint'
}
}
}
}
stage('Build') {
agent {
docker {
image "node:${NODE_VERSION}-alpine"
}
}
when {
branch 'main'
}
steps {
unstash 'node_modules'
sh 'npm run build'
stash includes: 'dist/**', name: 'dist'
archiveArtifacts artifacts: 'dist/**', fingerprint: true
}
}
stage('Deploy to Staging') {
agent any
when {
branch 'main'
}
steps {
unstash 'dist'
script {
sshagent(['deploy-key']) {
sh '''
rsync -avz --delete dist/ [email protected]:/var/www/app/
ssh [email protected] "pm2 reload ecosystem.config.js --env staging"
'''
}
}
}
}
stage('Deploy to Production') {
agent any
when {
branch 'main'
}
steps {
input message: 'Deploy to production?', ok: 'Deploy'
unstash 'dist'
script {
sshagent(['deploy-key']) {
sh '''
rsync -avz --delete dist/ [email protected]:/var/www/app/
ssh [email protected] "pm2 reload ecosystem.config.js --env production"
'''
}
}
}
}
}
post {
failure {
emailext(
subject: "Build Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "Check console output at ${env.BUILD_URL}",
to: "${env.CHANGE_AUTHOR_EMAIL}"
)
}
}
}
This Jenkinsfile uses Docker agents to ensure consistent Node versions across all stages. The stash/unstash pattern efficiently moves node_modules and build artifacts between stages without reinstalling dependencies. Parallel testing runs unit tests, integration tests, and linting simultaneously—cutting total pipeline time in half compared to sequential execution.
Managing Build Agent Capacity
Jenkins build agents are physical or virtual machines that execute pipeline stages. The bottleneck in self-hosted Jenkins is agent availability—if all agents are busy, new builds queue until an agent becomes free. For Node.js projects with 8-minute builds and ten daily commits per developer across a team of five, you need at least 2-3 dedicated agents to avoid queue times exceeding 5 minutes.
Docker Build Optimization for Node.js
Most Node.js Docker images are unnecessarily large—1GB+ for applications that should be 100-200MB. The culprit is usually unnecessary dependencies (devDependencies in the final image), multiple layers that aren't consolidated, and base images that include tools the application never uses.
Multi-Stage Build Pattern
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json package-lock.json ./
# Install ALL dependencies (including dev)
RUN npm ci
# Copy application code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Copy only package manifests
COPY package.json package-lock.json ./
# Install ONLY production dependencies
RUN npm ci --omit=dev
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "dist/main.js"]
The builder stage includes devDependencies because TypeScript compilation, webpack bundling, or other build tools require them. The production stage starts fresh, installs only runtime dependencies (--omit=dev), and copies the compiled output from the builder stage. The final image contains no TypeScript compiler, no webpack, no ESLint—just the minimal runtime dependencies.
Size Comparison
| Build Approach | Image Size | Build Time | Security Risk |
|---|---|---|---|
| node:20 (full image) | 1.1 GB | 3 min | High (many packages) |
| node:20-alpine | 450 MB | 2 min | Medium |
| Multi-stage with alpine | 180 MB | 2.5 min | Low |
| Distroless base | 120 MB | 3 min | Very low |
Smaller images deploy faster, cost less to store in registries, and present fewer attack surfaces. A 180MB image uploads to production in 20 seconds over a typical connection; a 1.1GB image takes 2+ minutes. Over dozens of deployments per week, this compounds into significant time savings.
Environment Variable Management
Node.js applications fail in production because environment variables that existed in development are missing in production, or exist but contain incorrect values. The standard .env file approach works in development but breaks down when you need different values for staging, production, and feature branch deployments.
Secure Variable Injection Pattern
// Environment configuration with validation
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
SENTRY_DSN: z.string().url().optional(),
});
export const env = envSchema.parse(process.env);
// CI/CD configuration (GitHub Actions example)
deploy_production:
runs-on: ubuntu-latest
steps:
- name: Deploy with secrets
run: |
docker run -d \
-e NODE_ENV=production \
-e DATABASE_URL=${{ secrets.PRODUCTION_DATABASE_URL }} \
-e REDIS_URL=${{ secrets.PRODUCTION_REDIS_URL }} \
-e JWT_SECRET=${{ secrets.JWT_SECRET }} \
-e SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
myapp:latest
The Zod schema validates environment variables at application startup—if DATABASE_URL is missing or malformed, the application fails immediately with a clear error message instead of crashing unpredictably when the database connection is first attempted. This fail-fast approach catches configuration errors before they reach production users.
Multi-Environment Strategy
Store secrets in your CI platform's encrypted secret storage (GitHub Secrets, GitLab CI/CD Variables, Jenkins Credentials). Never commit .env files to version control, even for development. Instead, maintain a .env.example file with placeholder values:
# .env.example
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=generate-a-random-32-character-string
AWS_ACCESS_KEY_ID=your-aws-key
AWS_SECRET_ACCESS_KEY=your-aws-secret
Developers copy .env.example to .env and fill in real values for their local environment. .env stays gitignored, so secrets never reach the repository. CI/CD pipelines inject production secrets at deploy time from the platform's secret storage.
Database Migration Automation
Running database migrations in CI/CD pipelines is risky—migrations can fail halfway through, leaving the database in an inconsistent state. But manual migrations before deployments create deployment friction and human error opportunities. The solution is automated migrations with proper rollback strategies.
Prisma Migration in Pipeline
deploy_with_migrations:
stage: deploy
script:
# 1. Deploy new code without starting the app
- scp -r dist/ $SERVER:/opt/app/releases/$CI_COMMIT_SHA/
# 2. Run migrations
- ssh $SERVER "cd /opt/app/releases/$CI_COMMIT_SHA && npx prisma migrate deploy"
# 3. If migrations succeed, switch symlink and restart
- ssh $SERVER "
ln -sfn /opt/app/releases/$CI_COMMIT_SHA /opt/app/current &&
pm2 reload ecosystem.config.js
"
# 4. Wait for health check
- sleep 5
- curl -f https://example.com/health || exit 1
only:
- main
This pattern deploys code to a releases directory without starting the application, runs migrations, then atomically switches a symlink to the new release. If migrations fail, the symlink still points to the old working version and the application continues running. If migrations succeed but the application fails health checks, you can quickly roll back by reverting the symlink.
Handling Migration Failures
Migrations fail for predictable reasons: constraint violations in existing data, timeouts on large tables, or deadlocks during heavy traffic. The mitigation is to make migrations backward-compatible whenever possible. Instead of dropping a column immediately, follow this pattern:
- Deploy code that no longer uses the column (but column still exists)
- Run application for 24-48 hours to ensure stability
- Deploy migration that drops the column
This three-step approach ensures you can roll back code without schema mismatches. If the new code has bugs, rolling back doesn't break because the old column is still there. Once you're confident in the new code, you clean up the schema.
Rollback Strategies
Every deployment needs a rollback plan that executes in under 2 minutes. The fastest rollback is reverting a symlink or switching a load balancer back to the previous container. The slowest rollback is git revert → rebuild → redeploy, which can take 10+ minutes while users experience errors.
Blue-Green Deployment Pattern
deploy_blue_green:
stage: deploy
script:
# Determine which environment is currently active
- ACTIVE=$(ssh $SERVER "readlink /opt/app/current | grep -o 'blue\|green'")
- NEW=$([ "$ACTIVE" = "blue" ] && echo "green" || echo "blue")
# Deploy to inactive environment
- ssh $SERVER "
mkdir -p /opt/app/$NEW &&
rm -rf /opt/app/$NEW/* &&
cp -r /opt/app/releases/$CI_COMMIT_SHA/* /opt/app/$NEW/
"
# Start app in new environment
- ssh $SERVER "pm2 start /opt/app/$NEW/ecosystem.config.js --name app-$NEW"
# Wait for health check
- sleep 10
- curl -f https://$NEW.internal.example.com/health || exit 1
# Switch production traffic
- ssh $SERVER "
ln -sfn /opt/app/$NEW /opt/app/current &&
nginx -s reload
"
# Stop old environment (but don't delete it)
- ssh $SERVER "pm2 stop app-$ACTIVE"
Blue-green deployments maintain two complete environments. Production traffic routes to one (blue), while you deploy to the other (green). After verifying green works correctly, you switch traffic to green. Blue stays running but idle, allowing instant rollback if green develops problems under production load.
Cost vs Speed Tradeoffs
Blue-green deployments double infrastructure costs—you're running two full environments. For most teams, the compromise is to spin up the inactive environment only during deployment, then tear it down after confirming stability. This captures most of the safety benefits while minimizing cost overhead.
Monitoring and Alerting Integration
Your CI/CD pipeline should report deployment outcomes to your monitoring system so you can correlate application errors with deployments. If error rates spike within 5 minutes of a deployment, you know the deployment caused the issue. Without this correlation, debugging production incidents takes significantly longer.
Deployment Markers in Sentry
after_deploy:
stage: notify
script:
- npx @sentry/cli releases new $CI_COMMIT_SHA
- npx @sentry/cli releases set-commits $CI_COMMIT_SHA --auto
- npx @sentry/cli releases finalize $CI_COMMIT_SHA
- npx @sentry/cli releases deploys $CI_COMMIT_SHA new -e production
only:
- main
This configuration tells Sentry when deployments occur and which git commits are included. Sentry's dashboard shows error trends with deployment markers overlaid, making it obvious when a deployment introduced regressions. You can also configure Sentry to automatically assign errors to the developers whose commits are included in the problematic release.
FAQ
Should I run npm install or npm ci in CI pipelines?
Always use npm ci in CI/CD pipelines. npm install can modify package-lock.json if it detects version inconsistencies, creating non-reproducible builds. npm ci deletes node_modules and installs exactly what's in package-lock.json, failing if there are mismatches. This strictness is what you want in CI—surprises should happen in development, not production.
How do I speed up npm install in CI?
Cache node_modules or ~/.npm between builds, use npm ci --prefer-offline to prefer cached packages, and consider switching to pnpm which parallelizes installation. The biggest win is usually caching—a cache hit reduces installation from 90 seconds to 10 seconds on typical projects.
Should I commit package-lock.json or yarn.lock?
Yes, always commit lock files. They ensure every developer and CI build uses identical dependency versions. Without lock files, you'll encounter "works on my machine" issues where different Node versions or npm versions resolve dependencies differently.
How do I handle native dependencies in Docker builds?
Native dependencies (node-gyp, bcrypt, sharp) compile against the OS where npm install runs. If you run npm install on macOS then copy node_modules to a Linux Docker image, native modules won't work. Always run npm install inside the Docker build process using the same base image you'll use in production.
What's the best way to run integration tests that need a database?
Use Docker Compose to start PostgreSQL, Redis, or other services as part of your CI pipeline. Most CI platforms support service containers (GitHub Actions services, GitLab services directive). Tests run against real databases, catching issues that mocks miss, then containers terminate automatically after tests complete.
How do I deploy to multiple environments (staging, production)?
Use branch-based deployments: develop branch triggers staging deployment, main branch triggers production. Store environment-specific secrets (API keys, database URLs) in your CI platform's secret storage, injecting different values depending on which environment you're deploying to.
Should I build Docker images in CI or use pre-built images?
Build Docker images in CI using the exact commit being deployed. Pre-built images lose traceability—you can't easily determine which code version is in which image. Building in CI ensures the image tag (often the git commit SHA) precisely identifies the code version.
How do I roll back a failed deployment?
The fastest rollback is reverting infrastructure to point to the previous release. If you use Docker, this means reverting the image tag. If you deploy files to a server, use symlinks where "current" points to the active release and rollback just changes the symlink. Never rely on rebuilding old code in an emergency—pre-deploy rollback paths.
What's the difference between CI and CD in practice?
CI (Continuous Integration) runs tests and builds on every commit, ensuring code changes integrate without breaking existing functionality. CD (Continuous Deployment) automatically deploys passing builds to staging or production. Most teams implement CI fully but make production deployments semi-automatic, requiring manual approval after automated staging deployment succeeds.
How do I prevent secrets from leaking in CI logs?
Use your CI platform's secret management (GitHub Secrets, GitLab Variables with "masked" flag). Never echo secrets in scripts. Configure your application logger to explicitly redact sensitive fields. Review CI logs before making repositories public—even deleted secrets remain in git history.
Conclusion
A production-ready CI/CD pipeline for Node.js optimizes for dependency installation speed, parallelizes tests intelligently, and maintains clear rollback paths. The specific platform choice (GitHub Actions, GitLab CI, CircleCI, Jenkins) matters less than implementing proper caching, environment variable management, and deployment verification.
Start with the simplest pipeline that catches errors before production: install dependencies, run tests, deploy on success. Add complexity only when you encounter specific bottlenecks—test suite taking too long (add parallelization), deployments causing downtime (add blue-green deployment), debugging production incidents difficult (add deployment markers to monitoring).
The goal isn't the most sophisticated pipeline—it's the pipeline that prevents production incidents while maintaining fast feedback loops for developers. Every optimization should serve one of those objectives.