Node.js projects have a particular rhythm — fast dependency installs, quick test suites, and a deployment story that varies wildly depending on whether you’re shipping an API, a static frontend, or a full-stack app. I’ve set up Jenkins for all three flavors over the years, and this guide distills that into a single, practical walkthrough: from a blank Jenkins install to a pipeline that lints, tests, builds, and deploys a Node.js application automatically.
Jenkins Architecture for Node.js Projects
- Controller: Orchestrates the pipeline; doesn’t need Node.js installed itself.
- Agent: Where
npm/yarn/pnpmcommands actually run — needs Node.js installed, either directly or via a Docker image or the NodeJS plugin’s managed installations. - Pipeline stages: Checkout, install, lint, test, build, package, deploy — a fairly standard shape regardless of the deployment target.
Prerequisites
- Jenkins (2.4+ LTS)
- A Node.js application with a
package.jsonand lockfile committed - A deployment target already decided (this guide covers a generic server/PM2 deploy and a Docker-based deploy)
- Git repository access configured in Jenkins
Step 1: Install the NodeJS Plugin
In Manage Jenkins > Plugins, install the NodeJS Plugin. Then under Manage Jenkins > Tools, add a NodeJS installation:
- Name:
Node20 - Version: pick the LTS version matching your project’s
.nvmrcorenginesfield inpackage.json - Enable “Install automatically”
This lets any pipeline reference tools { nodejs 'Node20' } and get a consistent Node version without manually installing it on every agent.
Step 2: Write a Baseline Jenkinsfile
pipeline {
agent any
tools {
nodejs 'Node20'
}
environment {
CI = 'true'
}
stages {
stage('Checkout') {
steps {
git branch: 'main', url: 'https://github.com/yourorg/myapp.git'
}
}
stage('Install Dependencies') {
steps {
sh 'npm ci'
}
}
stage('Lint') {
steps {
sh 'npm run lint'
}
}
stage('Unit Tests') {
steps {
sh 'npm test -- --coverage'
}
post {
always {
junit 'reports/junit.xml'
publishHTML(target: [
reportDir: 'coverage/lcov-report',
reportFiles: 'index.html',
reportName: 'Coverage Report'
])
}
}
}
stage('Build') {
steps {
sh 'npm run build'
}
}
stage('Package') {
steps {
sh 'tar -czf app-$BUILD_NUMBER.tar.gz dist/ package.json package-lock.json'
archiveArtifacts artifacts: "app-${env.BUILD_NUMBER}.tar.gz", fingerprint: true
}
}
}
}
npm ci (not npm install) is important here — it installs exactly what’s in the lockfile, fails on any mismatch, and is noticeably faster in CI environments since it skips dependency resolution.
Step 3: Add Dependency Vulnerability Scanning
stage('Audit Dependencies') {
steps {
sh 'npm audit --audit-level=high'
}
}
For a non-blocking report instead of a hard failure, pipe to a file and publish it rather than letting npm audit‘s exit code fail the build outright — useful early on when a project has a backlog of known, accepted vulnerabilities to work through.
Step 4: Deploying to a Server via PM2 and SSH
A common pattern for Node.js APIs: deploy the built artifact to a server and restart it under PM2, a process manager that keeps the app running and handles restarts.
stage('Deploy via SSH') {
steps {
sshagent(['deploy-server-ssh-key']) {
sh '''
scp -o StrictHostKeyChecking=no app-$BUILD_NUMBER.tar.gz deploy@myserver:/opt/myapp/releases/
ssh -o StrictHostKeyChecking=no deploy@myserver '
cd /opt/myapp/releases &&
tar -xzf app-'"$BUILD_NUMBER"'.tar.gz -C app-'"$BUILD_NUMBER"' &&
ln -sfn /opt/myapp/releases/app-'"$BUILD_NUMBER"' /opt/myapp/current &&
cd /opt/myapp/current &&
npm ci --production &&
pm2 reload myapp --update-env
'
'''
}
}
}
This is a lightweight blue-green-style pattern: each release goes into its own timestamped/numbered directory, and a symlink swap (current) makes the switch atomic, with pm2 reload performing a zero-downtime restart.
Step 5: Dockerized Node.js Deployment
If you’re containerizing the app instead:
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
stage('Build and Push Docker Image') {
steps {
sh '''
docker build -t myrepo/myapp:$BUILD_NUMBER .
docker push myrepo/myapp:$BUILD_NUMBER
'''
}
}
stage('Deploy Container') {
steps {
sshagent(['deploy-server-ssh-key']) {
sh '''
ssh -o StrictHostKeyChecking=no deploy@myserver '
docker pull myrepo/myapp:'"$BUILD_NUMBER"' &&
docker stop myapp || true &&
docker rm myapp || true &&
docker run -d --name myapp -p 3000:3000 --restart unless-stopped myrepo/myapp:'"$BUILD_NUMBER"'
'
'''
}
}
}
The multi-stage Dockerfile keeps the final image lean by discarding build-time dependencies and only shipping the compiled output plus production node_modules.
Step 6: Deploying a Frontend Build to Static Hosting
If the Node.js project is a frontend app (React, Vue, etc.) that builds to static files, the deployment target is usually S3+CloudFront, Netlify, or similar rather than a running Node process:
stage('Deploy Static Build to S3') {
steps {
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'aws-s3-deploy']]) {
sh '''
aws s3 sync dist/ s3://my-frontend-bucket --delete
aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DIST_ID --paths "/*"
'''
}
}
}
Step 7: Environment-Specific Configuration
Node.js apps typically rely on environment variables for configuration. Keep secrets out of the repo and inject them at deploy time via Jenkins Credentials:
stage('Deploy with Env Config') {
steps {
withCredentials([
string(credentialsId: 'db-connection-string', variable: 'DATABASE_URL'),
string(credentialsId: 'api-secret-key', variable: 'API_SECRET')
]) {
sshagent(['deploy-server-ssh-key']) {
sh '''
ssh deploy@myserver "echo 'DATABASE_URL=$DATABASE_URL' > /opt/myapp/current/.env &&
echo 'API_SECRET=$API_SECRET' >> /opt/myapp/current/.env &&
pm2 reload myapp --update-env"
'''
}
}
}
}
Real-World Workflow
- A PR triggers lint, unit tests, and dependency audit — no deployment.
- Merge to
mainruns the full pipeline: build, package (or Docker image), and deploy to staging automatically. - A smoke test hits the staging
/healthendpoint. - A manual approval gates promotion to production, where the same artifact (not a rebuild) is deployed to avoid any “it built differently the second time” surprises.
pm2 reloador a container restart performs a near-zero-downtime cutover; PM2’s cluster mode restarts workers one at a time by default.
Best Practices
- Always deploy the exact artifact that was tested, never rebuild between test and deploy stages — this avoids dependency resolution drift.
- Pin your Node.js version in both
.nvmrc/package.jsonenginesand the Jenkins NodeJS tool configuration so local dev and CI never diverge. - Use
npm ci, notnpm install, in every CI stage. - Cache
node_modulesor the npm cache directory between builds on persistent agents to speed up install time, but always validate with a clean install periodically to catch cache-related bugs. - Run lint and unit tests in parallel stages where possible to shorten feedback time.
Troubleshooting
- “npm ci” fails with lockfile mismatch: Someone edited
package.jsonwithout regeneratingpackage-lock.json— regenerate locally and commit both together. - PM2 shows app as “errored” after deploy: Check
pm2 logs myappon the server; often a missing environment variable or a port already in use from the previous process not fully stopping. - Docker build succeeds locally but fails in Jenkins: Usually a
.dockerignoredifference or the Jenkins agent’s Docker daemon caching an old base image layer — trydocker build --no-cacheto confirm. - Build is slow: Profile with
npm ci --loglevel verboseand consider a private npm registry proxy/cache (like Verdaccio or Artifactory) to speed up dependency resolution across builds.
Speeding Up Builds with Dependency Caching
Node.js installs can dominate build time, especially on ephemeral agents that start from a clean filesystem every run. A few caching strategies that make a real difference:
Cache the npm cache directory across builds (works well with persistent agents):
stage('Install Dependencies') {
steps {
sh 'npm ci --cache .npm-cache --prefer-offline'
}
}
Use a local registry proxy like Verdaccio or a hosted Artifactory/Nexus instance so package downloads hit a fast internal cache instead of the public npm registry on every build — this helps every project on the Jenkins instance, not just one pipeline.
Parallelize independent stages so lint, unit tests, and dependency audit run concurrently instead of sequentially:
stage('Quality Gates') {
parallel {
stage('Lint') {
steps { sh 'npm run lint' }
}
stage('Unit Tests') {
steps { sh 'npm test' }
}
stage('Audit') {
steps { sh 'npm audit --audit-level=high' }
}
}
}
Since these three checks don’t depend on each other’s output, running them in parallel can cut several minutes off total pipeline time on larger projects.
Monorepo Considerations
If your Node.js codebase is a monorepo (using npm/Yarn/pnpm workspaces, Turborepo, or Nx), avoid rebuilding and redeploying every package on every commit. Use change detection to scope the pipeline to only what actually changed:
stage('Detect Changed Packages') {
steps {
script {
env.CHANGED_PACKAGES = sh(
script: "git diff --name-only origin/main...HEAD | grep '^packages/' | cut -d/ -f2 | sort -u | tr '\\n' ' '",
returnStdout: true
).trim()
}
}
}
stage('Build Changed Packages Only') {
when {
expression { env.CHANGED_PACKAGES?.trim() }
}
steps {
sh "npx turbo run build --filter=${env.CHANGED_PACKAGES.split(' ').collect { '...' + it }.join(' ')}"
}
}
This keeps CI fast as the monorepo grows, since a change to one small package doesn’t trigger a full rebuild and redeploy of every service in the repository.
FAQs
Should I use npm, Yarn, or pnpm in a Jenkins pipeline? Whichever your project already uses — the CI equivalents are npm ci, yarn install --frozen-lockfile, and pnpm install --frozen-lockfile; the important part is using the lockfile-strict install command, not the tool itself.
How do I run Node.js integration tests that need a real database in Jenkins? Spin up a database container as part of the pipeline (via docker run or Docker Compose) before the test stage, and tear it down in a post { always } block.
Can Jenkins deploy a Node.js app to a serverless platform instead of a persistent server? Yes — the packaging and deploy stages would target AWS Lambda, Vercel, or similar instead of PM2/Docker; the build/test stages stay identical.
How do I handle zero-downtime deploys without Docker or PM2? Use a process manager or supervisor with graceful reload support (systemd with ExecReload, or a load balancer with connection draining across multiple app instances) so in-flight requests complete before the old process exits.
Summary
A solid Node.js Jenkins pipeline follows a predictable shape: install with a lockfile-strict command, lint, test with coverage, build, package once, and deploy that exact package — whether that’s via SSH and PM2, a Docker container, or a static file sync to S3. The deployment mechanics change based on your architecture, but the discipline of testing once and deploying the tested artifact stays constant.