⚙️ Jenkins · CI/CD

CI/CD Pipeline for a React App

⏱ 40 minutes⚙️ Jenkins 2.440+⛑️ React 18🐳 Docker

This tutorial builds a complete Jenkins pipeline with declarative Jenkinsfile: Git checkout, dependency installation, unit tests, Docker image build, push to registry, and automated deployment — all automated on every push.

📖 Key CI/CD Terms

CI/CD: Continuous Integration / Continuous Deployment. Automating compilation, testing, and deployment every time you push code. Versus slow manual deployments.

Pipeline: Sequence of automated steps to process your code (compile → test → package → deploy). Each step is a "stage".

Stage: One step in the pipeline (e.g., "Build", "Test", "Deploy"). Stages execute sequentially by default but can run in parallel.

Jenkins: Open-source server that executes pipelines. Can be triggered by a Git webhook (code pushed = pipeline launched automatically).

Jenkinsfile: File describing the pipeline as code (versioned with your source). Two syntaxes: declarative (simple) or scripted (flexible).

Webhook: HTTP URL that Git calls every time you push code. Jenkins listens and starts the pipeline automatically.

Credential: Sensitive data stored securely in Jenkins (tokens, SSH keys, passwords). Never in plain text in the Jenkinsfile.

Artifact: Result of a pipeline step (e.g., compiled files, Docker images, test reports). Jenkins can archive and serve them.

Pipeline Architecture

CI/CD Flow
Git Push → Jenkins Webhook
    └── Stage 1 : Checkout (clone code)
    └── Stage 2 : Install (npm ci)
    └── Stage 3 : Lint & Test (ESLint + Jest) [parallel]
    └── Stage 4 : Build (npm run build → dist/)
    └── Stage 5 : Docker Build & Push (image :latest + :git-sha)
    └── Stage 6 : Deploy (docker pull + restart on SSH server)
    └── Stage 7 : Notify (Slack / Email)
This pipeline automates everything: when you push code to the main branch, Jenkins clones your repo, compiles, tests, creates a Docker image, pushes it to the registry, then runs it on your production server. No manual actions required. Most steps execute sequentially, except Lint and Tests which run in parallel (time savings).

1. The React Application

Let's create a basic React application with unit tests. Jenkins will execute these tests automatically.

Terminal — create the app
npx create-react-app my-dashboard
cd my-dashboard

# Install test dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom
create-react-app : Generator that creates a React project with webpack, Babel, and Jest pre-configured.
@testing-library/react : Library for testing React components by rendering and querying the DOM.
jest-dom : Additional Jest matchers (e.g., toBeInTheDocument()).

Add a simple unit test:

src/App.test.js
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from './App';

test('renders application title', () => {
  render(<App />);
  const title = screen.getByText(/My Dashboard/i);
  expect(title).toBeInTheDocument();
});

test('navigation is visible', () => {
  render(<App />);
  expect(screen.getByRole('navigation')).toBeInTheDocument();
});
test(description, callback) : Defines a unit test.
render(<App />) : Renders the React component in the test DOM.
screen.getByText / getByRole : Queries the DOM to verify that the component displays expected content.
expect().toBeInTheDocument() : Verifies that the element exists. Jenkins executes these tests — if one fails, the pipeline stops.

2. Dockerfile for the React Application

Use a multi-stage build: compile with Node in stage 1, then serve static files with Nginx in stage 2. Advantage: the final image contains only Nginx + compiled files, without Node.js.

Dockerfile
# ── Stage 1 : Build ──
FROM node:20-alpine AS builder
WORKDIR /app

# Install dependencies (optimized cache)
COPY package*.json ./
RUN npm ci

# Copy code and compile
COPY . .
RUN npm run build

# ── Stage 2 : Serve ──
FROM nginx:alpine AS runner

# Nginx config for React Router (SPA)
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy build from previous stage
COPY --from=builder /app/build /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Stage 1 (builder) : Installs npm and compiles React code. Generates a build/ folder with minified HTML and optimized JavaScript.
Stage 2 (runner) : Lightweight Nginx image serving files from stage 1. The entire Node.js folder is discarded. Result: image ~50 MB instead of 500 MB.
COPY --from=builder : Copies compiled files from the previous stage into the Nginx image.
nginx.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Required for React Router (all routes → index.html)
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache for assets (JS, CSS, images)
    location ~* \.(js|css|png|jpg|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Health check for orchestrators
    location /health {
        return 200 '{"status":"healthy"}';
        add_header Content-Type application/json;
    }
}
try_files $uri $uri/ /index.html : Crucial for React SPA. Every route not found routes to index.html, which loads React Router and displays the correct page.
expires 1y : Caches static assets for 1 year (JS/CSS files only change if the hash in the filename changes).
/health endpoint : Used by orchestrators (Docker Compose, Kubernetes) to check that Nginx is healthy.

3. Jenkinsfile — the Declarative Pipeline

The Jenkinsfile describes the pipeline in Groovy code. Declarative syntax: readable, structured, with pipeline { agent ... environment ... stages ... }.

Jenkinsfile (at project root)
pipeline {
    agent any

    // ── Pipeline environment variables ──
    environment {
        REGISTRY = 'registry.example.com'
        IMAGE_NAME = 'my-dashboard'
        IMAGE_TAG = "${REGISTRY}/${IMAGE_NAME}:${GIT_COMMIT[0..7]}"
        IMAGE_LATEST = "${REGISTRY}/${IMAGE_NAME}:latest"
        DEPLOY_SERVER = 'user@my-server.com'
        NODE_VERSION = '20'
    }

    // ── Pipeline options ──
    options {
        buildDiscarder(logRotator(numToKeepStr: '10'))
        timeout(time: 20, unit: 'MINUTES')
        disableConcurrentBuilds()  // No parallel builds on same branch
    }

    stages {

        // ── Stage 1 : Checkout ──
        stage('📥 Checkout') {
            steps {
                checkout scm
                sh 'git log --oneline -5'  // Display last commits
                script {
                    env.GIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
                    env.BRANCH = env.GIT_BRANCH.replace('origin/', '')
                }
                echo "Branch: ${env.BRANCH} | Commit: ${env.GIT_SHORT}"
            }
        }

        // ── Stage 2 : Install dependencies ──
        stage('📦 Install') {
            steps {
                sh '''
                    node --version
                    npm --version
                    npm ci --prefer-offline
                '''
            }
        }

        // ── Stage 3 : Tests (parallel) ──
        stage('🧪 Lint & Test') {
            parallel {
                stage('ESLint') {
                    steps {
                        sh 'npm run lint -- --format checkstyle --output-file reports/eslint.xml || true'
                    }
                    post {
                        always {
                            // Publish ESLint results in Jenkins
                            recordIssues tools: [esLint(pattern: 'reports/eslint.xml')]
                        }
                    }
                }
                stage('Unit Tests') {
                    steps {
                        sh '''
                            npm test -- \
                              --watchAll=false \
                              --coverage \
                              --coverageReporters=cobertura \
                              --coverageDirectory=reports/coverage \
                              --reporters=jest-junit \
                              --testResultsProcessor=jest-junit
                        '''
                    }
                    post {
                        always {
                            junit 'reports/junit.xml'
                            cobertura coberturaReportFile: 'reports/coverage/cobertura-coverage.xml'
                        }
                    }
                }
            }
        }

        // ── Stage 4 : Build ──
        stage('🔨 Build') {
            steps {
                sh '''
                    GENERATE_SOURCEMAP=false npm run build
                    echo "Build complete — bundle size :"
                    du -sh build/
                '''
                archiveArtifacts artifacts: 'build/**', fingerprint: true
            }
        }

        // ── Stage 5 : Docker Build & Push ──
        stage('🐳 Docker') {
            when {
                anyOf {
                    branch 'main'
                    branch 'staging'
                }
            }
            steps {
                script {
                    withCredentials([usernamePassword(
                        credentialsId: 'docker-registry-credentials',
                        usernameVariable: 'DOCKER_USER',
                        passwordVariable: 'DOCKER_PASS'
                    )]) {
                        sh """
                            echo "$DOCKER_PASS" | docker login ${REGISTRY} -u "$DOCKER_USER" --password-stdin
                            docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} .
                            docker push ${IMAGE_TAG}
                            docker push ${IMAGE_LATEST}
                            docker logout ${REGISTRY}
                        """
                    }
                }
            }
        }

        // ── Stage 6 : Deployment ──
        stage('🚀 Deploy') {
            when { branch 'main' }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(
                        credentialsId: 'deploy-ssh-key',
                        keyFileVariable: 'SSH_KEY'
                    )]) {
                        sh """
                            ssh -i $SSH_KEY -o StrictHostKeyChecking=no ${DEPLOY_SERVER} \\
                              "docker pull ${IMAGE_TAG} && \\
                               docker stop my-dashboard || true && \\
                               docker rm my-dashboard || true && \\
                               docker run -d --name my-dashboard \\
                                 -p 80:80 \\
                                 --restart unless-stopped \\
                                 ${IMAGE_TAG}"
                        """
                    }
                }
            }
        }

    }

    // ── Post-build notifications ──
    post {
        success {
            echo "✅ Build ${env.BUILD_NUMBER} succeeded — ${env.BRANCH}:${env.GIT_SHORT}"
        }
        failure {
            echo "❌ Build ${env.BUILD_NUMBER} failed"
        }
        always {
            // Workspace cleanup
            cleanWs()
        }
    }
}
pipeline { ... } : Root block of declarative syntax.
agent any : Executes the pipeline on any available node/worker.
environment { ... } : Environment variables injected into all steps. ${REGISTRY}/${IMAGE_NAME}:${GIT_COMMIT[0..7]} generates a tag like registry.example.com/my-dashboard:a1b2c3d4.
options { buildDiscarder ... } : Keeps the 10 most recent builds and cancels if > 20 min. Prevents Jenkins from filling the disk.
timeout : Stops the build if it takes > 20 minutes.

Detail of each stage

Stage 1 : Checkout
stage('📥 Checkout') {
    steps {
        checkout scm                              // Clone Git repo
        sh 'git log --oneline -5'                 // Display last commits
        script {
            env.GIT_SHORT = sh(script: ...).trim() // Short hash (a1b2c3d)
            env.BRANCH = env.GIT_BRANCH.replace() // Branch name without 'origin/'
        }
    }
}
checkout scm : Clones your Git repo (configured in the Jenkins job). scm = "Source Code Management".
git rev-parse --short HEAD : Retrieves short commit hash (first 7 characters). Used to tag the Docker image uniquely.
Stage 2 : Install
stage('📦 Install') {
    steps {
        sh '''
            node --version                        // Verify Node is available
            npm ci --prefer-offline               // Clean install (vs npm install)
        '''
    }
}
npm ci (clean install) : Installs exactly the versions in package-lock.json (reproducible, vs npm install which can vary). --prefer-offline : Uses local npm cache if available.
Stage 3 : Lint & Test (parallel)
stage('🧪 Lint & Test') {
    parallel {                                    // Two stages in parallel = faster
        stage('ESLint') {
            steps {
                sh 'npm run lint -- --format checkstyle ...'
            }
            post {
                always {
                    recordIssues tools: [esLint(...)]  // Jenkins displays warnings
                }
            }
        }
        stage('Unit Tests') {
            steps {
                sh 'npm test -- --watchAll=false --coverage ...'
            }
            post {
                always {
                    junit 'reports/junit.xml'  // Publish test results
                    cobertura ...              // Publish code coverage
                }
            }
        }
    }
}
parallel { ... } : Launches ESLint and Tests simultaneously. With 2 cores, this saves time (20s eslint + 20s tests = 20s total instead of 40s).
post { always { ... } } : Blocks always executed even if the step fails. Useful for collecting logs and reports.
recordIssues / junit / cobertura : Publish results in the Jenkins interface so you can see graphs and trends over time.
Stage 4 : Build
stage('🔨 Build') {
    steps {
        sh '''
            GENERATE_SOURCEMAP=false npm run build  // Compile React (optimized, sourcemaps off)
            du -sh build/                           // Display build size
        '''
        archiveArtifacts artifacts: 'build/**'      // Archive files for download
    }
}
GENERATE_SOURCEMAP=false : Reduces build size (no source maps = faster to zip).
npm run build : Minifies React code, generates build/ folder.
archiveArtifacts : Jenkins saves compiled files so you can download or inspect them later.
Stage 5 : Docker Build & Push
stage('🐳 Docker') {
    when { anyOf { branch 'main'; branch 'staging' } }  // Only on main/staging
    steps {
        script {
            withCredentials([usernamePassword(         // Encrypted credentials
                credentialsId: 'docker-registry-credentials',
                usernameVariable: 'DOCKER_USER',
                passwordVariable: 'DOCKER_PASS'
            )]) {
                sh """
                    echo "$DOCKER_PASS" | docker login ...  // Secure login (not plain text)
                    docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} .
                    docker push ${IMAGE_TAG}                // :a1b2c3d4 (commit hash)
                    docker push ${IMAGE_LATEST}             // :latest
                    docker logout ${REGISTRY}               // Cleanup
                """
            }
        }
    }
}
when { anyOf { branch 'main'; branch 'staging' } } : This stage executes only if code is on main or staging (not on every feature branch).
withCredentials : Jenkins retrieves Docker registry username/password securely (encrypted in database). Password never appears in logs.
docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} : Compiles the Dockerfile and creates two tags (e.g., registry.example.com/my-dashboard:a1b2c3d4 and :latest).
docker push : Sends image to registry so your deployment server can download it.
Two tags serve different purposes. The :latest tag always references "the most recent version". The hash-based tag (e.g., :a1b2c3d4) lets you trace exactly which code version is in production (useful if a deployment fails — you can rollback to the previous hash).
Stage 6 : Deploy (SSH)
stage('🚀 Deploy') {
    when { branch 'main' }                          // Only main deploys to prod
    steps {
        script {
            withCredentials([sshUserPrivateKey(
                credentialsId: 'deploy-ssh-key',
                keyFileVariable: 'SSH_KEY'
            )]) {
                sh """
                    ssh -i $SSH_KEY -o StrictHostKeyChecking=no ${DEPLOY_SERVER} \\
                      "docker pull ${IMAGE_TAG} && \\
                       docker stop my-dashboard || true && \\
                       docker rm my-dashboard || true && \\
                       docker run -d --name my-dashboard \\
                         -p 80:80 \\
                         --restart unless-stopped \\
                         ${IMAGE_TAG}"
                """
            }
        }
    }
}
withCredentials([sshUserPrivateKey]) : Retrieves your SSH private key stored securely in Jenkins.
ssh -i $SSH_KEY ... ${DEPLOY_SERVER} : Connects to your server (e.g., ubuntu@prod.example.com) with SSH key.
docker pull ${IMAGE_TAG} : Downloads the Docker image from registry onto the production server.
docker stop && docker rm || true : Stops and removes the old instance if it exists. || true prevents errors if the container doesn't exist.
docker run -d --name my-dashboard -p 80:80 --restart unless-stopped : Launches a new container. -p 80:80 exposes port 80 (HTTP). --restart unless-stopped automatically restarts the container if the instance crashes (but not if you stop it manually).
SSH is simpler and universal (works on any Linux server) compared to Kubernetes. Kubernetes would be more robust for true multi-instance deployments but is more complex to administer. SSH suits small deployments well.

SSH Deployment Security

🔒 Security Considerations

SSH Keys: Jenkins uses a private SSH key to connect to the server without a password. This key must be protected (permissions 600) and stored securely in Jenkins.

StrictHostKeyChecking=no: Disables host key verification (convenient in CI, but less secure — in production, prefer yes and pre-add the key).

Encrypted Credentials: Jenkins encrypts all secrets (passwords, keys) and injects them only during execution (never in logs).

4. Configure Jenkins

Terminal — launch Jenkins with Docker
# Launch Jenkins locally
docker run -d \
  --name jenkins \
  -p 8080:8080 \
  -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \  # Access host Docker daemon
  jenkins/jenkins:lts-jdk17

# Get initial password
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
-p 8080:8080 : Jenkins web interface on http://localhost:8080.
-p 50000:50000 : Port for Jenkins workers (if you add more later).
-v jenkins_home:/var/jenkins_home : Persists Jenkins data (jobs, builds, credentials) even if you restart the container.
-v /var/run/docker.sock:/var/run/docker.sock : Allows Jenkins to access the host's Docker daemon (Jenkins can run docker build and docker push commands). This is "Docker-in-Docker" via socket.
In Jenkins, add required credentials via Manage Jenkins → Credentials :

Create a Pipeline Job in Jenkins

📖 GitHub Webhook

Configure a webhook in your GitHub repo to trigger the Jenkins pipeline automatically: Settings → Webhooks → Add URL http://jenkins.example.com/github-webhook/ and select "Push events". Every git push, GitHub notifies Jenkins which launches the pipeline immediately.