Building Our First CI/CD Pipeline with Jenkins
We were deploying manually. SSH into servers, git pull, restart services. It took 45 minutes and failed 30% of the time due to human error.
I set up Jenkins CI/CD pipeline. Now deployments take 8 minutes, fully automated, with automated tests. We deploy 5x more frequently with fewer bugs.
Table of Contents
The Manual Deploy Process
Our painful process:
- SSH into 3 production servers
git pull origin masteron eachnpm install(if dependencies changed)npm run buildpm2 restart app- Test manually
- If broken, rollback (another 20 minutes)
Time: 45 minutes
Success rate: 70%
Deploys per week: 2-3
Installing Jenkins
Downloaded Jenkins 2.7.4:
wget http://mirrors.jenkins.io/war-stable/latest/jenkins.war
java -jar jenkins.war --httpPort=8080
Access at http://localhost:8080
Initial setup:
- Get admin password from
/var/jenkins_home/secrets/initialAdminPassword - Install suggested plugins
- Create admin user
First Pipeline Job
Created “Freestyle project”:
Source Code Management:
- Git repository:
https://github.com/company/web-app.git - Branch:
*/master
Build Triggers:
- Poll SCM:
H/5 * * * *(check every 5 minutes)
Build Steps:
npm install
npm test
npm run build
Post-build Actions:
- Archive artifacts:
dist/**/*
Simple but effective!
Pipeline as Code
Jenkins 2.0 introduced Jenkinsfile. Much better than UI configuration.
Created Jenkinsfile in repo:
node {
stage('Checkout') {
checkout scm
}
stage('Install Dependencies') {
sh 'npm install'
}
stage('Test') {
sh 'npm test'
}
stage('Build') {
sh 'npm run build'
}
stage('Archive') {
archiveArtifacts artifacts: 'dist/**/*', fingerprint: true
}
}
Created Pipeline job in Jenkins:
- Pipeline script from SCM
- Git repository URL
- Script path:
Jenkinsfile
Now pipeline is version controlled!
Adding Deployment
Extended Jenkinsfile:
node {
def app
stage('Checkout') {
checkout scm
}
stage('Install Dependencies') {
sh 'npm install'
}
stage('Test') {
sh 'npm test'
}
stage('Build') {
sh 'npm run build'
}
stage('Build Docker Image') {
app = docker.build("web-app:${env.BUILD_NUMBER}")
}
stage('Push to Registry') {
docker.withRegistry('https://registry.company.com', 'docker-credentials') {
app.push("${env.BUILD_NUMBER}")
app.push("latest")
}
}
stage('Deploy to Staging') {
sh """
ssh deploy@staging-server '
docker pull registry.company.com/web-app:${env.BUILD_NUMBER}
docker stop web-app || true
docker rm web-app || true
docker run -d --name web-app -p 80:3000 registry.company.com/web-app:${env.BUILD_NUMBER}
'
"""
}
stage('Smoke Test') {
sh 'curl -f http://staging-server/ || exit 1'
}
}
Parallel Stages
Run tests in parallel:
stage('Test') {
parallel(
'Unit Tests': {
sh 'npm run test:unit'
},
'Integration Tests': {
sh 'npm run test:integration'
},
'Lint': {
sh 'npm run lint'
}
)
}
Saves time!
Manual Approval for Production
Don’t auto-deploy to production:
stage('Deploy to Production') {
input message: 'Deploy to production?', ok: 'Deploy'
sh """
ssh deploy@prod-server-1 'docker pull registry.company.com/web-app:${env.BUILD_NUMBER} && docker service update --image registry.company.com/web-app:${env.BUILD_NUMBER} web-app'
ssh deploy@prod-server-2 'docker pull registry.company.com/web-app:${env.BUILD_NUMBER} && docker service update --image registry.company.com/web-app:${env.BUILD_NUMBER} web-app'
ssh deploy@prod-server-3 'docker pull registry.company.com/web-app:${env.BUILD_NUMBER} && docker service update --image registry.company.com/web-app:${env.BUILD_NUMBER} web-app'
"""
}
Requires manual click to deploy to production.
Environment Variables
Use different configs per environment:
environment {
STAGING_SERVER = 'staging.company.com'
PROD_SERVERS = 'prod1.company.com,prod2.company.com,prod3.company.com'
DOCKER_REGISTRY = 'registry.company.com'
}
stage('Deploy to Staging') {
sh """
ssh deploy@${env.STAGING_SERVER} '
docker pull ${env.DOCKER_REGISTRY}/web-app:${env.BUILD_NUMBER}
docker stop web-app || true
docker rm web-app || true
docker run -d --name web-app -p 80:3000 ${env.DOCKER_REGISTRY}/web-app:${env.BUILD_NUMBER}
'
"""
}
Credentials Management
Store secrets in Jenkins:
- Manage Jenkins → Manage Credentials
- Add credentials (username/password, SSH key, secret text)
- Use in pipeline:
stage('Deploy') {
withCredentials([sshUserPrivateKey(credentialsId: 'deploy-key', keyFileVariable: 'SSH_KEY')]) {
sh """
ssh -i ${SSH_KEY} deploy@server 'docker pull ...'
"""
}
}
Notifications
Slack notifications:
post {
success {
slackSend(
color: 'good',
message: "Build ${env.BUILD_NUMBER} succeeded: ${env.BUILD_URL}"
)
}
failure {
slackSend(
color: 'danger',
message: "Build ${env.BUILD_NUMBER} failed: ${env.BUILD_URL}"
)
}
}
Requires Slack plugin.
Multi-Branch Pipeline
Automatically create pipelines for each branch:
- Create “Multibranch Pipeline” job
- Configure Git repository
- Jenkins scans branches and creates pipeline for each
Now feature branches get their own CI pipeline!
Our Final Pipeline
pipeline {
agent any
environment {
DOCKER_REGISTRY = 'registry.company.com'
APP_NAME = 'web-app'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install') {
steps {
sh 'npm install'
}
}
stage('Test') {
parallel {
stage('Unit Tests') {
steps {
sh 'npm run test:unit'
}
}
stage('Integration Tests') {
steps {
sh 'npm run test:integration'
}
}
stage('Lint') {
steps {
sh 'npm run lint'
}
}
}
}
stage('Build') {
steps {
sh 'npm run build'
}
}
stage('Docker Build') {
steps {
script {
docker.build("${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER}")
}
}
}
stage('Docker Push') {
steps {
script {
docker.withRegistry("https://${env.DOCKER_REGISTRY}", 'docker-credentials') {
docker.image("${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER}").push()
docker.image("${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER}").push('latest')
}
}
}
}
stage('Deploy to Staging') {
steps {
sh """
ssh deploy@staging 'docker pull ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER} && \
docker stop ${env.APP_NAME} || true && \
docker rm ${env.APP_NAME} || true && \
docker run -d --name ${env.APP_NAME} -p 80:3000 ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER}'
"""
}
}
stage('Smoke Test') {
steps {
sh 'curl -f http://staging/ || exit 1'
}
}
stage('Deploy to Production') {
when {
branch 'master'
}
steps {
input message: 'Deploy to production?', ok: 'Deploy'
script {
def servers = ['prod1', 'prod2', 'prod3']
for (server in servers) {
sh """
ssh deploy@${server} 'docker pull ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER} && \
docker stop ${env.APP_NAME} || true && \
docker rm ${env.APP_NAME} || true && \
docker run -d --name ${env.APP_NAME} -p 80:3000 ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BUILD_NUMBER}'
"""
sleep 30 // Rolling deployment
}
}
}
}
}
post {
success {
slackSend color: 'good', message: "Build ${env.BUILD_NUMBER} succeeded"
}
failure {
slackSend color: 'danger', message: "Build ${env.BUILD_NUMBER} failed"
}
}
}
Results
Before Jenkins:
- Manual deploys: 45 minutes
- Success rate: 70%
- Deploys per week: 2-3
- No automated tests
- Frequent rollbacks
After Jenkins:
- Automated deploys: 8 minutes
- Success rate: 95%
- Deploys per week: 10-15
- All tests run automatically
- Rare rollbacks
Lessons Learned
- Start simple - Basic pipeline first, add features later
- Pipeline as code - Version control your Jenkinsfile
- Automate tests - Catch bugs before production
- Manual approval for prod - Don’t auto-deploy to production
- Monitor builds - Set up notifications
Conclusion
Jenkins transformed our deployment process. From error-prone manual deploys to reliable automated pipeline.
Key takeaways:
- CI/CD saves time and reduces errors
- Pipeline as code is essential
- Automated tests catch bugs early
- Start simple, iterate
- Manual approval for production
If you’re still deploying manually, stop. Set up Jenkins today. Your team will thank you.