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.
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.
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)
Let's create a basic React application with unit tests. Jenkins will execute these tests automatically.
npx create-react-app my-dashboard
cd my-dashboard
# Install test dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom
toBeInTheDocument()).
Add a simple unit test:
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();
});
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.
# ── 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;"]
build/ folder with minified HTML and optimized JavaScript.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;
}
}
The Jenkinsfile describes the pipeline in Groovy code. Declarative syntax: readable, structured, with pipeline { agent ... environment ... stages ... }.
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()
}
}
}
${REGISTRY}/${IMAGE_NAME}:${GIT_COMMIT[0..7]} generates a tag like registry.example.com/my-dashboard:a1b2c3d4.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/'
}
}
}
scm = "Source Code Management".stage('📦 Install') {
steps {
sh '''
node --version // Verify Node is available
npm ci --prefer-offline // Clean install (vs npm install)
'''
}
}
package-lock.json (reproducible, vs npm install which can vary). --prefer-offline : Uses local npm cache if available.
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
}
}
}
}
}
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
}
}
build/ folder.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
"""
}
}
}
}
main or staging (not on every feature branch).registry.example.com/my-dashboard:a1b2c3d4 and :latest).: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('🚀 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}"
"""
}
}
}
}
ubuntu@prod.example.com) with SSH key.|| true prevents errors if the container doesn't exist.-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 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).
# 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
http://localhost:8080.docker build and docker push commands). This is "Docker-in-Docker" via socket.
docker-registry-credentials — Docker registry username/passworddeploy-ssh-key — SSH key for deployment serverConfigure 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.