Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer
CI/CD 2025-10-01 16 min read

CI/CD Pipeline Best Practices: GitHub Actions vs GitLab CI vs Jenkins

Saddique Khan

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:

  1. Lint & static analysis, fast feedback, no infrastructure needed
  2. Unit tests, isolated, parallelisable
  3. Build, produce the artifact once; use it everywhere
  4. Security scan, SAST, dependency check, container scan
  5. 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.

ToolCache keyWhat to cache
npmpackage-lock.json hashnode_modules/
piprequirements.txt hash.pip-cache/
Mavenpom.xml hash~/.m2/repository
DockerLayer hashesRegistry cache (type=registry)
Gogo.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

StrategyDowntimeRollback speedComplexityBest for
Rolling updateNoneSlow (re-deploy)LowMost apps
Blue-greenNoneInstantMediumZero-tolerance downtime
CanaryNoneFastHighHigh-traffic, risk-averse
RecreateYesFastLowStateful 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

MetricGoalHow to measure
Deployment frequencyDaily or moreCount deployments/day
Lead time for changes< 1 hourCommit-to-deploy time
Change failure rate< 5%Incidents per deployment
MTTR< 1 hourTime 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.
#cicd #github-actions #gitlab-ci #jenkins
Found this useful? Share it with your team or link to it from your documentation.

Need help implementing this?

Our certified engineers can handle the setup, migration, or ongoing operations for you.