CI/CD Pipelines

TL;DR

CI/CD automates the journey from code commit to production. Continuous Integration merges and tests code automatically. Continuous Delivery makes every build deployable. Continuous Deployment pushes to production without human intervention.

Explain Like I'm 12

Imagine a factory assembly line for building toy cars. When you finish designing a new part (writing code), you put it on the conveyor belt. The first station checks if your part fits with all the other parts (integration tests). The next station paints and polishes it (build). Then a quality inspector tests that the car actually drives (end-to-end tests). Finally, the car rolls off the line and goes straight to the toy store shelf (deployment). CI/CD is that entire assembly line — automated, fast, and it catches broken parts before customers ever see them.

CI/CD pipeline flow: Code Commit triggers Build, then Test, then Package into Docker image, then Deploy to Staging, then Deploy to Production, with feedback loops back to developers

CI vs CD vs CD

These three acronyms get jumbled together constantly. Let’s untangle them once and for all:

Continuous Integration (CI) — Every developer pushes code to a shared repository multiple times a day. Each push triggers an automated build and test suite. The goal: catch integration bugs within minutes, not days. If the tests break, the team fixes them immediately. No more “it works on my machine.”

Continuous Delivery (CD) — Takes CI further. After code passes all tests, it’s automatically packaged and staged so it could be deployed to production at any moment. A human still clicks the “deploy” button, but the artifact is always ready. Think of it as “always releasable.”

Continuous Deployment (CD) — The final frontier. Every change that passes the full pipeline goes to production automatically — no human approval gate. This requires excellent test coverage and monitoring. Companies like Netflix and GitHub deploy hundreds of times per day this way.

AspectContinuous IntegrationContinuous DeliveryContinuous Deployment
TriggerEvery commitEvery commitEvery commit
Build & TestAutomatedAutomatedAutomated
Deploy to stagingNoAutomatedAutomated
Deploy to productionNoManual approvalAutomated
Risk levelLowMediumRequires robust testing
Key insight: You can’t do Continuous Delivery without Continuous Integration, and you can’t do Continuous Deployment without Continuous Delivery. Each level builds on the one before it.

Pipeline Stages

A CI/CD pipeline is a series of automated steps that take your code from a developer’s machine to production. Here’s what each stage does:

1
Source
Trigger on push, PR, or schedule. Clone the repo.
2
Build
Compile code, install dependencies, bundle assets.
3
Test
Unit tests, integration tests, e2e tests, linting.
4
Package
Build Docker image, create artifact, push to registry.
5
Deploy
Push to staging, run smoke tests, then promote to production.

Source — The pipeline starts when something changes. A developer pushes a commit, opens a pull request, or a scheduled cron fires. The CI server clones the repository and checks out the relevant branch.

Build — The code is compiled (if needed), dependencies are installed, and assets are bundled. For a Node.js app, this means npm install and npm run build. For Go, it’s go build. The output is a build artifact.

Test — The most critical stage. Run unit tests first (they’re fast and cheap). Then integration tests to verify components work together. Then end-to-end tests to simulate real user behavior. Also run linters and security scanners here.

Package — Wrap the tested artifact into a deployable format. Usually a Docker image tagged with the commit SHA or semantic version. Push it to a container registry (Docker Hub, GitHub Container Registry, ECR, GCR).

Deploy — Roll out the packaged artifact. Typically to a staging environment first for final validation, then to production. This is where deployment strategies (blue-green, canary, rolling) come into play.

Fail fast: Order your pipeline stages from cheapest to most expensive. Linting takes seconds, unit tests take a minute, e2e tests take 10 minutes. If a typo breaks linting, you don’t want to wait 10 minutes to find out.

GitHub Actions

GitHub Actions is the built-in CI/CD system for GitHub repositories. Workflows are defined in YAML files inside .github/workflows/. If your code lives on GitHub, Actions is the path of least resistance — no separate CI server to manage.

How It Works

You define a workflow (the YAML file) that contains one or more jobs. Each job runs on a runner (a VM provided by GitHub or self-hosted). Jobs contain steps — either shell commands or reusable actions from the marketplace.

Example: Node.js Build, Test, and Deploy

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run unit tests
        run: npm test

      - name: Build application
        run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

  deploy:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Deploy to production
        run: |
          echo "Deploying to production..."
          # Replace with your actual deploy command
          # e.g., aws s3 sync dist/ s3://my-bucket/
          # or: kubectl apply -f k8s/deployment.yaml

Let’s break down the key pieces:

  • on: — Triggers. This fires on pushes to main and on PRs targeting main.
  • runs-on: — The runner OS. ubuntu-latest is the most common (free for public repos).
  • cache: 'npm' — Caches node_modules between runs for faster installs.
  • npm ci — Clean install from lockfile (faster and more reproducible than npm install).
  • needs: build-and-test — The deploy job waits for build-and-test to succeed.
  • environment: production — Enables environment protection rules (approvals, secrets scoping).
Reusable workflows: If you have 10 repos with similar pipelines, extract the common logic into a reusable workflow (workflow_call trigger) in a shared repo. Then each repo calls it with uses: org/shared-workflows/.github/workflows/ci.yml@main. One place to update, 10 repos benefit.

Jenkins

Jenkins is the granddaddy of CI/CD — open-source, self-hosted, and infinitely customizable. It runs on your own servers (or a VM/container you manage). Pipelines are defined in a Jenkinsfile using Groovy-based DSL.

When to Choose Jenkins

Jenkins shines when you need full control: air-gapped environments, custom hardware for builds, or complex enterprise workflows with dozens of plugins. The trade-off is maintenance — you manage the Jenkins server, agents, plugins, and security yourself.

Example: Declarative Jenkinsfile

// Jenkinsfile (Declarative Pipeline)
pipeline {
    agent any

    environment {
        DOCKER_REGISTRY = 'registry.example.com'
        APP_NAME        = 'my-app'
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Build') {
            steps {
                sh 'npm ci'
                sh 'npm run build'
            }
        }

        stage('Test') {
            parallel {
                stage('Unit Tests') {
                    steps {
                        sh 'npm run test:unit'
                    }
                }
                stage('Integration Tests') {
                    steps {
                        sh 'npm run test:integration'
                    }
                }
            }
        }

        stage('Package') {
            steps {
                sh """
                    docker build -t ${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_NUMBER} .
                    docker push ${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_NUMBER}
                """
            }
        }

        stage('Deploy to Staging') {
            steps {
                sh "kubectl set image deployment/${APP_NAME} ${APP_NAME}=${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_NUMBER} -n staging"
            }
        }

        stage('Deploy to Production') {
            input {
                message 'Deploy to production?'
                ok 'Yes, deploy it!'
            }
            steps {
                sh "kubectl set image deployment/${APP_NAME} ${APP_NAME}=${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_NUMBER} -n production"
            }
        }
    }

    post {
        failure {
            slackSend channel: '#deployments',
                      message: "FAILED: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
        }
        success {
            slackSend channel: '#deployments',
                      message: "SUCCESS: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
        }
    }
}

Key Jenkins concepts in this example:

  • agent any — Run on any available Jenkins agent (worker node).
  • parallel — Run unit and integration tests simultaneously to save time.
  • input — Pauses the pipeline and waits for a human to approve the production deploy (Continuous Delivery, not Deployment).
  • post — Runs after all stages, regardless of result. Great for notifications.
Plugin management is a full-time job. Jenkins has 1,800+ plugins, and they don’t always play nicely together. Pin your plugin versions, test upgrades in a staging Jenkins instance, and keep the plugin count minimal. An unmaintained Jenkins server with 50 outdated plugins is a security and stability risk.

GitLab CI

GitLab CI is built directly into GitLab — no separate tool to install. You define your pipeline in a .gitlab-ci.yml file at the root of your repo. GitLab’s killer feature is that CI/CD, container registry, package registry, environments, and monitoring are all in one platform.

Example: .gitlab-ci.yml

# .gitlab-ci.yml
stages:
  - build
  - test
  - package
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  NODE_VERSION: "20"

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/

build:
  stage: build
  image: node:${NODE_VERSION}
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

unit-tests:
  stage: test
  image: node:${NODE_VERSION}
  script:
    - npm ci
    - npm run test:unit
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'

integration-tests:
  stage: test
  image: node:${NODE_VERSION}
  services:
    - postgres:15
  variables:
    POSTGRES_DB: testdb
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: secret
  script:
    - npm ci
    - npm run test:integration

package:
  stage: package
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE

deploy-production:
  stage: deploy
  image: bitnami/kubectl:latest
  environment:
    name: production
    url: https://myapp.example.com
  script:
    - kubectl set image deployment/myapp myapp=$DOCKER_IMAGE
  when: manual
  only:
    - main

What stands out in GitLab CI:

  • services: — Spin up sidecar containers (like Postgres) for integration tests. No external test database needed.
  • artifacts: — Pass build output between stages automatically.
  • coverage: — Extract test coverage from console output with a regex. GitLab displays it in merge requests.
  • when: manual — The production deploy requires a click (Continuous Delivery). Remove this line for Continuous Deployment.
  • $CI_REGISTRY_IMAGE — GitLab’s built-in container registry, no Docker Hub account needed.
Built-in container registry: Every GitLab project gets a free container registry at registry.gitlab.com/your-group/your-project. Your pipeline can build, push, and pull Docker images without configuring any external registry. This simplifies the package stage significantly.

Comparison Table

Choosing a CI/CD tool depends on where your code lives, your budget, and how much infrastructure you want to manage:

FeatureGitHub ActionsJenkinsGitLab CI
HostingCloud (GitHub-hosted runners) or self-hostedSelf-hosted onlyCloud (GitLab.com) or self-hosted
Config formatYAML (.github/workflows/)Groovy (Jenkinsfile)YAML (.gitlab-ci.yml)
PricingFree for public repos; 2,000 min/mo free for privateFree (open source); you pay for infraFree tier: 400 min/mo; paid plans scale up
Ecosystem17,000+ marketplace actions1,800+ pluginsBuilt-in features (registry, SAST, DAST)
StrengthsTight GitHub integration, easy setup, great DXMaximum flexibility, any language/platformAll-in-one platform (SCM + CI + registry + monitoring)
WeaknessesVendor lock-in to GitHub, runner minute limitsComplex setup, plugin conflicts, maintenance burdenSlower UI, YAML can get complex for large pipelines
Best forTeams already on GitHub, open-source projectsEnterprise with complex requirements, air-gapped envsTeams wanting one platform for everything

Deployment Strategies

Getting code to production is one thing. Getting it there safely is another. Here are the four strategies you’ll encounter most:

Blue-Green Deployment

Run two identical production environments: Blue (current live) and Green (new version). Deploy the new version to Green. Test it. Then switch the load balancer to point traffic from Blue to Green. If anything goes wrong, switch back instantly.

  • When to use: You need zero-downtime deployments and instant rollback.
  • Trade-off: You pay for double the infrastructure during the switch.

Canary Deployment

Roll out the new version to a small percentage of users (say 5%). Monitor error rates and performance. If everything looks good, gradually increase to 25%, 50%, then 100%. If something breaks, only 5% of users were affected.

  • When to use: High-traffic apps where you want real-world validation before full rollout.
  • Trade-off: More complex routing and monitoring setup required.

Rolling Deployment

Replace instances of the old version one at a time. If you have 10 servers, update server 1, wait for it to be healthy, then update server 2, and so on. At any point, a mix of old and new versions is running.

  • When to use: Standard approach for Kubernetes deployments. Good balance of safety and resource efficiency.
  • Trade-off: During the rollout, users might hit either the old or new version (must be backward-compatible).

Feature Flags

Deploy the new code to production but keep it hidden behind a flag. The code is live but inactive. Flip the flag to enable it for specific users, teams, or percentages. This decouples deployment from release.

  • When to use: Long-running features, A/B testing, gradual rollouts without separate infrastructure.
  • Trade-off: Flag management becomes its own complexity. Clean up old flags regularly or they accumulate.
These strategies are not mutually exclusive. Many teams combine them — for example, using canary deployment with feature flags to test a new feature with 5% of users before flipping the flag for everyone.

Best Practices

CI/CD golden rules for fast, reliable pipelines:
  • Keep pipelines fast (under 10 minutes). Developers won’t wait 30 minutes for feedback. If your pipeline is slow, parallelize tests, cache dependencies, and trim unnecessary steps.
  • Fail fast. Run linting and unit tests before integration and e2e tests. If a semicolon is missing, find out in 30 seconds, not 10 minutes.
  • Cache dependencies. Downloading 500 MB of node_modules on every run is waste. All major CI tools support caching — use it.
  • Use environment variables for secrets. Never hardcode API keys, passwords, or tokens in your pipeline YAML. Use your CI tool’s secrets management (GitHub Secrets, Jenkins Credentials, GitLab CI Variables).
  • Pin dependency versions. Use lockfiles (package-lock.json, Pipfile.lock) and pin Docker base image versions. node:latest today is not node:latest tomorrow.
  • Run pipelines on every PR. Don’t merge code that hasn’t been through the pipeline. Enable branch protection rules to enforce this.
  • Monitor pipeline metrics. Track build times, failure rates, and flaky tests. A test that fails randomly 10% of the time erodes trust in the entire pipeline.
  • Keep pipeline config in the repo. Jenkinsfile, .github/workflows/, or .gitlab-ci.yml should be versioned alongside the code. Pipeline changes go through the same PR review process.

Test Yourself

Q: What is the difference between Continuous Delivery and Continuous Deployment?

Continuous Delivery means every build is automatically tested and packaged so it could go to production, but a human must approve the final deploy. Continuous Deployment removes the human gate entirely — every change that passes the pipeline goes to production automatically.

Q: Name the five core stages of a typical CI/CD pipeline in order.

(1) Source — trigger on code change, clone the repo. (2) Build — compile, install dependencies. (3) Test — run unit, integration, and e2e tests. (4) Package — create a deployable artifact (e.g., Docker image). (5) Deploy — roll out to staging, then production.

Q: Why should you order pipeline stages from cheapest to most expensive?

This is the fail fast principle. Cheap operations like linting (seconds) catch simple errors before expensive operations like e2e tests (minutes) run. If a syntax error would fail the e2e test anyway, you save time and compute by catching it during linting.

Q: What does the needs keyword in a GitHub Actions workflow do?

The needs keyword creates a dependency between jobs. A job with needs: build-and-test will only run after the build-and-test job succeeds. Without needs, jobs run in parallel by default.

Q: When would you choose a canary deployment over a blue-green deployment?

Canary is better when you want real-world validation with minimal blast radius — send 5% of traffic to the new version, monitor, then gradually increase. Blue-green is better when you want instant, all-or-nothing cutover with instant rollback. Canary requires more sophisticated traffic routing and monitoring, but catches issues that only appear under real user load.

Interview Questions

Q: Your CI pipeline takes 45 minutes. How do you bring it under 10 minutes?

Systematic approach: (1) Profile the pipeline to find the slowest stages. (2) Parallelize tests — split the test suite across multiple runners. (3) Cache aggressively — dependencies, Docker layers, build outputs. (4) Run only affected tests — use tools that detect which tests need re-running based on changed files. (5) Optimize Docker builds — multi-stage builds, smaller base images, layer ordering. (6) Move heavy tests (e2e, performance) to a separate pipeline that runs on merge, not on every commit. (7) Use faster runners (more CPU/RAM) if the bottleneck is compute.

Q: How do you handle database migrations in a CI/CD pipeline?

Key principles: (1) Migrations must be backward-compatible — the old code version must still work during the rollout (add new columns as nullable, don’t rename or delete columns in the same deploy). (2) Run migrations as a separate pipeline step before deploying the new app version. (3) Test migrations against a copy of production data in CI. (4) Use a migration tool (Flyway, Liquibase, Alembic, Rails Migrations) to version and track applied migrations. (5) Have a rollback plan — write “down” migrations for every “up.”

Q: A critical security vulnerability is discovered in production. Walk me through the emergency fix process with CI/CD.

(1) Assess severity and decide on immediate mitigation (feature flag off, WAF rule, etc.). (2) Create a hotfix branch from main. (3) Write the fix + a regression test that specifically covers the vulnerability. (4) Open a PR and let the CI pipeline run (but expedite the review). (5) Merge to main — the CD pipeline deploys to staging automatically. (6) Verify on staging with the regression test. (7) Promote to production (manual approval if Continuous Delivery, automatic if Continuous Deployment). (8) Post-incident: add security scanning to the pipeline (SAST/DAST) to catch similar issues earlier.

Q: Compare self-hosted runners vs cloud-hosted runners. When would you choose each?

Cloud-hosted (GitHub-hosted runners, GitLab SaaS): Zero maintenance, pre-configured environments, auto-scaling. Best for most teams. Downside: limited customization, runner minute costs, slower for large builds. Self-hosted: Full control over hardware, OS, and installed tools. Best for: (1) air-gapped/compliance environments, (2) GPU builds (ML), (3) builds needing specific hardware, (4) high-volume pipelines where cloud runner costs exceed infrastructure costs. Downside: you maintain the servers, scaling, security patches, and uptime.

Q: How do you keep secrets secure in a CI/CD pipeline?

(1) Never hardcode secrets in YAML, code, or Dockerfiles. (2) Use your CI tool’s built-in secrets management (GitHub Secrets, GitLab CI Variables marked “protected/masked”, Jenkins Credentials). (3) Scope secrets to specific environments (production secrets shouldn’t be accessible from PR builds). (4) Rotate secrets regularly and revoke any that may have been exposed. (5) Mask secrets in logs — most CI tools do this automatically for declared secrets. (6) For advanced setups, use a secrets manager (HashiCorp Vault, AWS Secrets Manager) and have the pipeline fetch secrets at runtime. (7) Audit access to secrets and review who can modify pipeline configuration.