CI/CD Pipeline Best Practices: GitHub Actions vs GitLab CI vs Jenkins
DevOps / SRE Engineer & CEO, Cloud DevOps Solutions
A well-designed CI/CD pipeline turns a commit into a production deployment in minutes, with confidence. A poorly designed one is slow, flaky, and a security liability. Here is what production-ready looks like in 2026.
The five pipeline stages
Every pipeline should have these stages in order:
- Lint & static analysis, fast feedback, no infrastructure needed
- Unit tests, isolated, parallelisable
- Build, produce the artifact once; use it everywhere
- Security scan, SAST, dependency check, container scan
- Deploy, environment-gated, with rollback
Never skip stages to save time. A failed deploy is 10x slower than a failed lint.
GitHub Actions: production-ready workflow
# .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run type-check
test:
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.shard }}
path: coverage/
build:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.tags }}
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=,suffix=,format=short
type=ref,event=branch
- id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
scan:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ needs.build.outputs.image }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
deploy-staging:
runs-on: ubuntu-latest
needs: scan
environment: staging
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
helm upgrade --install my-app ./helm/my-app \
--namespace staging \
--set image.tag=${{ github.sha }} \
--atomic --timeout 5m
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment: production # requires manual approval in GitHub Environments
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
helm upgrade --install my-app ./helm/my-app \
--namespace production \
--set image.tag=${{ github.sha }} \
--atomic --timeout 5m
GitLab CI: equivalent pipeline
# .gitlab-ci.yml
stages: [lint, test, build, scan, deploy]
variables:
DOCKER_DRIVER: overlay2
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
lint:
stage: lint
image: node:20-alpine
cache:
key: node-$CI_COMMIT_REF_SLUG
paths: [node_modules/]
script:
- npm ci
- npm run lint
- npm run type-check
test:
stage: test
image: node:20-alpine
cache:
key: node-$CI_COMMIT_REF_SLUG
paths: [node_modules/]
parallel: 4
script:
- npm ci
- npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
build:
stage: build
image: docker:24
services: [docker:24-dind]
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build --cache-from $IMAGE_TAG -t $IMAGE_TAG .
- docker push $IMAGE_TAG
scan:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image --exit-code 1 --severity CRITICAL,HIGH $IMAGE_TAG
deploy-staging:
stage: deploy
environment:
name: staging
url: https://staging.clouddevopsol.com
only: [main]
script:
- helm upgrade --install my-app ./helm/my-app
--namespace staging
--set image.tag=$CI_COMMIT_SHORT_SHA
--atomic
deploy-production:
stage: deploy
environment:
name: production
url: https://clouddevopsol.com
when: manual
only: [main]
script:
- helm upgrade --install my-app ./helm/my-app
--namespace production
--set image.tag=$CI_COMMIT_SHORT_SHA
--atomic
Jenkins: declarative pipeline
// Jenkinsfile
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: node
image: node:20-alpine
command: [cat]
tty: true
- name: docker
image: docker:24-dind
securityContext:
privileged: true
'''
}
}
environment {
REGISTRY = 'registry.example.com'
IMAGE = "${REGISTRY}/my-app:${GIT_COMMIT[0..7]}"
}
stages {
stage('Lint') {
steps {
container('node') {
sh 'npm ci && npm run lint'
}
}
}
stage('Test') {
steps {
container('node') {
sh 'npm test -- --coverage'
}
}
post {
always {
junit 'test-results/*.xml'
publishCoverage adapters: [coberturaAdapter('coverage/cobertura.xml')]
}
}
}
stage('Build & Push') {
steps {
container('docker') {
withCredentials([usernamePassword(
credentialsId: 'registry-creds',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
)]) {
sh '''
docker login -u $REG_USER -p $REG_PASS $REGISTRY
docker build -t $IMAGE .
docker push $IMAGE
'''
}
}
}
}
stage('Deploy to Staging') {
when { branch 'main' }
steps {
sh "helm upgrade --install my-app ./helm/my-app --set image.tag=${GIT_COMMIT[0..7]} -n staging --atomic"
}
}
stage('Deploy to Production') {
when { branch 'main' }
input { message 'Deploy to production?' }
steps {
sh "helm upgrade --install my-app ./helm/my-app --set image.tag=${GIT_COMMIT[0..7]} -n production --atomic"
}
}
}
}
Caching strategies
Slow pipelines kill developer productivity. Cache aggressively.
| Tool | Cache key | What to cache |
|---|---|---|
| npm | package-lock.json hash | node_modules/ |
| pip | requirements.txt hash | .pip-cache/ |
| Maven | pom.xml hash | ~/.m2/repository |
| Docker | Layer hashes | Registry cache (type=registry) |
| Go | go.sum hash | ~/go/pkg/mod |
GitHub Actions example:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
Docker layer caching with BuildKit registry mode:
- uses: docker/build-push-action@v5
with:
cache-from: type=registry,ref=ghcr.io/org/app:buildcache
cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max
Deployment strategies
| Strategy | Downtime | Rollback speed | Complexity | Best for |
|---|---|---|---|---|
| Rolling update | None | Slow (re-deploy) | Low | Most apps |
| Blue-green | None | Instant | Medium | Zero-tolerance downtime |
| Canary | None | Fast | High | High-traffic, risk-averse |
| Recreate | Yes | Fast | Low | Stateful apps, DB migrations |
Kubernetes blue-green with labels:
# Deploy green
kubectl set image deployment/my-app-green app=myapp:v2
# Switch traffic
kubectl patch service my-app -p '{"spec":{"selector":{"version":"green"}}}'
# Rollback: switch back to blue
kubectl patch service my-app -p '{"spec":{"selector":{"version":"blue"}}}'
DORA metrics to track
| Metric | Goal | How to measure |
|---|---|---|
| Deployment frequency | Daily or more | Count deployments/day |
| Lead time for changes | < 1 hour | Commit-to-deploy time |
| Change failure rate | < 5% | Incidents per deployment |
| MTTR | < 1 hour | Time from alert to resolution |
Key takeaways
- Build once, deploy everywhere, never rebuild the same commit twice.
- Parallelise tests with sharding; a 20-minute test suite can run in 5 minutes with 4 shards.
- Scan container images for CVEs before deploying, block on CRITICAL severity.
- Use environments with required approvals for production; manual gates prevent accidents.
- Cache aggressively, most pipeline time is downloading dependencies you already have.
- DORA metrics are the north star; track them from day one.