How to Set Up Jenkins for Node.js Continuous Deployment

How to Set Up Jenkins for Node.js Continuous Deployment

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

Prerequisites

Step 1: Install the NodeJS Plugin

In Manage Jenkins > Plugins, install the NodeJS Plugin. Then under Manage Jenkins > Tools, add a NodeJS installation:

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

  1. A PR triggers lint, unit tests, and dependency audit — no deployment.
  2. Merge to main runs the full pipeline: build, package (or Docker image), and deploy to staging automatically.
  3. A smoke test hits the staging /health endpoint.
  4. 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.
  5. pm2 reload or a container restart performs a near-zero-downtime cutover; PM2’s cluster mode restarts workers one at a time by default.

Best Practices

Troubleshooting

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.

References

Exit mobile version