DevOps CI/CD Pipeline Tutorial: Jenkins vs GitHub Actions
A hands-on guide to building CI/CD pipelines with Jenkins and GitHub Actions — covering pipeline-as-code, test integration, deployment stages, and how to choose between the two tools.
Continuous Integration and Continuous Delivery (CI/CD) is the practice of automating the build, test, and deployment lifecycle so that every code change moves through a consistent, reliable pipeline. Without it, software delivery is slow, error-prone, and dependent on heroic manual effort. With it, teams ship multiple times per day with confidence.
This guide walks through building practical CI/CD pipelines with the two most widely used tools: Jenkins (self-hosted, highly customisable) and GitHub Actions (cloud-native, zero infrastructure). By the end, you'll understand how to choose between them and how to build production-ready pipelines with either.
What a CI/CD Pipeline Does
A CI/CD pipeline is a set of automated steps that runs every time code changes. A typical pipeline:
- Triggers on a push, pull request, or tag
- Checks out the code
- Installs dependencies
- Runs linting and static analysis — catch style issues and obvious errors early
- Runs unit tests — fast feedback on business logic
- Runs integration/API tests — validate service boundaries
- Builds the application (compile, bundle, Docker image)
- Runs E2E tests against the built artefact
- Publishes test reports and artefacts
- Deploys to staging (on merge to main) and production (on tag or approval)
The key principle: fail fast, fail clearly. Each stage should catch a specific category of problem and report it clearly so the developer can fix it immediately.
Jenkins
Jenkins is a self-hosted, open-source automation server. It's been the standard CI tool in enterprise environments for over a decade, offering unmatched flexibility and a massive plugin ecosystem (1,800+ plugins).
When to choose Jenkins
- You need to run tests on on-premise infrastructure (security, compliance, hardware requirements)
- You have complex, highly customised pipeline logic that GitHub Actions' YAML doesn't express well
- Your organisation already has Jenkins infrastructure investment
- You need integration with on-premise tools (Active Directory, internal Artifactory, etc.)
Jenkinsfile basics
Modern Jenkins uses Declarative Pipeline defined in a Jenkinsfile committed to your repository:
pipeline {
agent any
environment {
NODE_VERSION = '20'
STAGING_URL = credentials('staging-url')
}
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '10'))
}
stages {
stage('Install') {
steps {
sh 'node --version'
sh 'npm ci'
}
}
stage('Lint & Type Check') {
steps {
sh 'npm run lint'
sh 'npm run type-check'
}
}
stage('Unit Tests') {
steps {
sh 'npm test -- --coverage --reporter=junit --outputFile=test-results/unit.xml'
}
post {
always {
junit 'test-results/unit.xml'
publishHTML([
reportDir: 'coverage',
reportFiles: 'lcov-report/index.html',
reportName: 'Coverage Report'
])
}
}
}
stage('Build') {
steps {
sh 'npm run build'
archiveArtifacts artifacts: 'dist/**', fingerprint: true
}
}
stage('E2E Tests') {
steps {
sh 'npx playwright install --with-deps chromium'
sh 'npx playwright test --reporter=junit'
}
post {
always {
junit 'test-results/e2e.xml'
publishHTML([
reportDir: 'playwright-report',
reportFiles: 'index.html',
reportName: 'Playwright Report'
])
}
}
}
stage('Deploy to Staging') {
when {
branch 'main'
}
steps {
sh './scripts/deploy.sh staging'
echo "Deployed to ${STAGING_URL}"
}
}
}
post {
failure {
// Notify Slack on failure
slackSend(
channel: '#ci-alerts',
color: 'danger',
message: "Build FAILED: ${env.JOB_NAME} #${env.BUILD_NUMBER} - ${env.BUILD_URL}"
)
}
success {
slackSend(
channel: '#ci-alerts',
color: 'good',
message: "Build PASSED: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
)
}
}
}Parallel stages in Jenkins
Running test stages in parallel dramatically reduces pipeline time:
stage('Tests') {
parallel {
stage('Unit Tests') {
steps { sh 'npm test' }
}
stage('API Tests') {
steps { sh 'npx playwright test tests/api/' }
}
stage('Lint') {
steps { sh 'npm run lint' }
}
}
}GitHub Actions
GitHub Actions is GitHub's native CI/CD system. Workflows are defined in YAML files in .github/workflows/. There's no server to maintain — GitHub provides compute on demand.
When to choose GitHub Actions
- Your code is on GitHub (the integration is seamless)
- You want zero infrastructure to manage
- Your team wants to move fast with minimal DevOps overhead
- You need access to GitHub's marketplace of pre-built Actions
A complete workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
NODE_VERSION: '20'
jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run type-check
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: http://localhost:3000
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [unit-tests, e2e-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./scripts/deploy.sh staging
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
STAGING_HOST: ${{ secrets.STAGING_HOST }}Matrix builds — test across multiple environments
jobs:
test:
name: Test on Node ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm testCaching to speed up workflows
Cache node_modules and Playwright browsers to avoid re-downloading on every run:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # Caches npm cache automatically
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}Jenkins vs GitHub Actions Comparison
| Jenkins | GitHub Actions | |
|---|---|---|
| Infrastructure | Self-hosted (you manage) | Cloud (GitHub manages) |
| Cost | Server costs + maintenance | Free for public repos; minutes-based for private |
| Setup time | Hours-days | Minutes |
| Flexibility | Extremely high | High (with limitations) |
| Plugin ecosystem | 1,800+ plugins | 20,000+ marketplace Actions |
| Scripting | Groovy (Declarative or Scripted) | YAML |
| Secret management | Jenkins credentials store | GitHub secrets |
| On-prem support | ✅ | ❌ (cloud only, or GitHub Enterprise) |
| GitHub integration | Via plugin | Native |
Pipeline Best Practices
Fail fast. Put the fastest, most likely-to-fail checks (lint, type-check) first. Don't run a 20-minute E2E suite before catching a syntax error.
Cache aggressively. Dependency installation is often the slowest step. Cache node_modules, Maven's .m2, pip's package cache, and browser binaries between runs.
Keep secrets out of logs. Never echo a secret variable. Use GitHub's masked secrets or Jenkins credentials to prevent accidental exposure in logs.
Use environments for deployment gates. GitHub's Environment protection rules (require approval before deploying to production) and Jenkins's input step provide manual gates where automation isn't appropriate.
Notify on failure immediately. Teams that discover CI failures hours after they happened move slower. Set up Slack, Teams, or email notifications on pipeline failure so the responsible developer sees it within minutes.
Archive test artefacts. Always upload test reports, coverage reports, and Playwright traces even on failure. You need these to debug what went wrong.
Integrating Quality Gates
A CI pipeline without quality gates is just a build server. Quality gates enforce standards:
# Fail the build if coverage drops below threshold
- name: Check coverage threshold
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold of 70%"
exit 1
fiCommon quality gates to enforce:
- Unit test pass rate: 100% (no failing tests merged)
- Code coverage: minimum threshold per repository
- E2E test pass rate: 100% on main branch merges
- Linting: zero warnings on new code
- Security scanning: no new high/critical vulnerabilities
What's Next
With a CI/CD pipeline in place, the next level is:
- Deployment automation — blue/green deployments, canary releases, automatic rollback on failure metrics
- Test environment automation — provision ephemeral test environments per PR using Docker Compose or Kubernetes
- Observability in production — feed production error rates back into your quality metrics
For more on the DevOps principles behind CI/CD, see our DevOps guide. For test automation to run in your pipeline, see our Playwright and API Testing guides.