CI/CD automates the steps between writing code and running it in production. Instead of manual builds, tests, and deploys, a pipeline does it all — every time someone pushes a commit.
CI vs CD: what's the difference?
Three terms get used interchangeably but they mean different things:
| Term | Full name | What it does |
|---|---|---|
| CI | Continuous Integration | Automatically build and test every commit |
| CD | Continuous Delivery | Automatically prepare a release-ready artifact |
| CD | Continuous Deployment | Automatically deploy to production after tests pass |
Continuous Integration is the foundation. Every commit triggers a build and a test suite. If tests fail, the team knows within minutes — before the broken code reaches anyone else.
Continuous Delivery extends CI by packaging the app and making it ready to deploy at any time. A human still presses the deploy button, but the process is fully automated.
Continuous Deployment removes that human step. Every commit that passes all checks ships to production automatically.
Most teams run CI + Continuous Delivery. Full Continuous Deployment requires very high test confidence and feature flags for incomplete work.
CI/CD pipeline stages
A typical pipeline runs these stages in order:
| Stage | What happens | Fails if… |
|---|---|---|
| Source | Trigger on push / PR | — |
| Install | Restore dependencies | Package not found, lockfile mismatch |
| Lint | Check code style | ESLint/flake8/golangci-lint errors |
| Build | Compile or bundle | TypeScript errors, missing imports |
| Test | Run unit + integration tests | Any test fails |
| Security scan | Check dependencies for CVEs | Critical vulnerability found |
| Build image | docker build |
Dockerfile error |
| Push image | Push to registry | Auth failure, disk quota |
| Deploy staging | Apply to staging environment | Health check fails |
| E2E tests | Run browser/API tests | Critical flow broken |
| Deploy production | Apply to production | (gated by approval in CD mode) |
Stages run top to bottom. An early failure cancels the rest — no point deploying code that doesn't compile.
GitHub Actions (the most common tool)
GitHub Actions is free for public repos and generous for private ones. Workflows live in .github/workflows/.
Basic Node.js pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Set up Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Test
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
Add a deploy job (runs only on main)
deploy:
needs: test # waits for test job to pass
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image
run: |
docker tag myapp:${{ github.sha }} ghcr.io/${{ github.repository }}:${{ github.sha }}
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Deploy to production
run: |
ssh deploy@${{ secrets.PROD_HOST }} \
"docker pull ghcr.io/${{ github.repository }}:${{ github.sha }} && \
docker stop myapp || true && \
docker run -d --name myapp -p 3000:3000 \
ghcr.io/${{ github.repository }}:${{ github.sha }}"
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
Useful GitHub Actions patterns
# Cache dependencies for faster builds
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# Run only when relevant files change
on:
push:
paths:
- 'src/**'
- 'package.json'
- 'Dockerfile'
# Set environment variables per job
env:
NODE_ENV: test
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
# Manual approval gate before production deploy
- name: Wait for approval
uses: trstringer/manual-approval@v1
with:
secret: ${{ github.TOKEN }}
approvers: username1,username2
GitLab CI
GitLab CI uses .gitlab-ci.yml in the repo root. Runners execute jobs — GitLab provides shared runners or you can host your own.
# .gitlab-ci.yml
stages:
- install
- test
- build
- deploy
variables:
NODE_VERSION: "20"
DOCKER_DRIVER: overlay2
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
install:
stage: install
image: node:${NODE_VERSION}
script:
- npm ci
lint_and_test:
stage: test
image: node:${NODE_VERSION}
script:
- npm run lint
- npm run type-check
- npm test -- --coverage --coverageReporters=cobertura
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
build_image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
only:
- main
deploy_production:
stage: deploy
script:
- kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
environment:
name: production
url: https://myapp.example.com
when: manual # requires a human click in GitLab UI
only:
- main
Jenkins declarative pipeline
Jenkins uses a Jenkinsfile at the repo root. More complex to set up than GitHub Actions or GitLab CI, but highly flexible for on-premises environments.
// Jenkinsfile
pipeline {
agent any
tools {
nodejs '20'
}
environment {
REGISTRY = 'registry.example.com'
IMAGE = "${REGISTRY}/myapp"
}
stages {
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('Test') {
steps {
sh 'npm test'
}
post {
always {
junit 'test-results/*.xml'
}
}
}
stage('Build image') {
when { branch 'main' }
steps {
sh "docker build -t ${IMAGE}:${GIT_COMMIT} ."
}
}
stage('Deploy') {
when { branch 'main' }
steps {
withCredentials([sshUserPrivateKey(
credentialsId: 'deploy-key',
keyFileVariable: 'SSH_KEY'
)]) {
sh """
ssh -i $SSH_KEY deploy@prod.example.com \
'docker pull ${IMAGE}:${GIT_COMMIT} && \
docker restart myapp'
"""
}
}
}
}
post {
failure {
slackSend(color: 'danger', message: "Build failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}")
}
}
}
CI/CD tools comparison
| Tool | Host | Free tier | Config | Best for |
|---|---|---|---|---|
| GitHub Actions | Cloud | 2,000 min/mo (private) | YAML | GitHub repos, open source |
| GitLab CI | Cloud / self-hosted | 400 min/mo | YAML | GitLab repos, self-hosted |
| CircleCI | Cloud | 6,000 min/mo | YAML | Fast Docker builds |
| Jenkins | Self-hosted | Free | Groovy DSL | On-premises, complex pipelines |
| Bitbucket Pipelines | Cloud | 50 min/mo | YAML | Atlassian stack |
| Travis CI | Cloud | Paid (was free for OSS) | YAML | Legacy open source |
| Drone | Self-hosted | Free | YAML | Lightweight, container-native |
| Tekton | Self-hosted (k8s) | Free | CRDs | Kubernetes-native pipelines |
| Argo CD | Self-hosted (k8s) | Free | Declarative | GitOps deployments |
| AWS CodePipeline | Cloud (AWS) | 1 free pipeline | JSON/YAML | All-AWS stacks |
Rule of thumb:
- Using GitHub? → GitHub Actions
- Using GitLab or want self-hosted? → GitLab CI
- On-premises with complex requirements? → Jenkins
- Kubernetes with GitOps? → Argo CD + Tekton
Secrets and environment variables
Never hardcode credentials in your pipeline config. Every CI/CD tool has a secrets store:
# GitHub Actions — set in repo Settings → Secrets and variables → Actions
${{ secrets.DATABASE_URL }}
${{ secrets.AWS_ACCESS_KEY_ID }}
# GitLab CI — set in Project Settings → CI/CD → Variables
$DATABASE_URL
$AWS_ACCESS_KEY_ID
# Jenkins — use Credentials binding plugin
withCredentials([string(credentialsId: 'db-url', variable: 'DB_URL')]) {
sh 'echo $DB_URL'
}
Rotate secrets regularly. Audit who has access. Use short-lived tokens (OIDC with AWS/GCP) instead of long-lived keys where possible.
Branch strategy and when pipelines run
| Trigger | Typical action |
|---|---|
| PR/MR opened | Run lint + tests |
| Push to feature branch | Run lint + tests |
Push to main/master |
Full pipeline: test → build → deploy staging |
Tag v*.*.* created |
Build release artifact, deploy production |
| Schedule (nightly) | Full test suite, dependency audit |
| Manual trigger | Emergency deploy, rollback |
Best practices
Keep pipelines fast. Slow pipelines kill developer flow. Target under 5 minutes for the test stage. Cache dependencies. Run jobs in parallel where possible.
# GitHub Actions: parallel jobs
jobs:
unit-tests:
runs-on: ubuntu-latest
steps: [...]
integration-tests:
runs-on: ubuntu-latest
steps: [...]
lint:
runs-on: ubuntu-latest
steps: [...]
Fail fast. Put the quickest checks (lint, type check) before the slowest (E2E tests). Don't waste 10 minutes running Playwright if ESLint already found an error.
Use immutable artifacts. Build the Docker image once, push it with the commit SHA as the tag, then promote that exact image through staging → production. Never rebuild for production.
Test in production-like environments. Use Docker in CI to match your production runtime. If production uses PostgreSQL 16, your test database should too.
Pin your action versions. Use actions/checkout@v4 not actions/checkout@latest to avoid unexpected breaking changes.
Implement rollback. Know how to go back. Keep the previous Docker image in the registry. For Kubernetes, kubectl rollout undo does it in seconds.
Common CI/CD mistakes
| Mistake | Why it hurts | Fix |
|---|---|---|
| Secrets in pipeline YAML | Exposed in logs / git history | Use the tool's secrets store |
| Not caching dependencies | Every run downloads GB of packages | Add cache step with lockfile hash |
| Running tests against production DB | Destructive test data | Spin up a test DB in the pipeline |
| No parallel jobs | 20-minute pipelines | Split test suites, run in parallel |
| Deploying untested Docker image | Build once, test, then promote | Tag with commit SHA, never rebuild |
| No rollback plan | Broken deploy takes site down for hours | Document and test rollback procedure |
| Ignoring flaky tests | Eventually you ignore all failures | Quarantine flaky tests, fix them |
| Different envs between CI and prod | "Works on my machine" × CI | Use Docker in CI |
FAQ
What's the difference between CI/CD and DevOps? DevOps is a culture and set of practices that includes CI/CD. CI/CD is a specific technical implementation — the automated pipeline. DevOps also covers monitoring, incident response, infrastructure as code, and collaboration between dev and ops teams.
How long should a CI/CD pipeline take? For most apps: lint+test under 5 minutes, full pipeline (including Docker build and staging deploy) under 15 minutes. Longer than that and developers start bypassing it. Optimize with caching and parallel jobs.
Should I use feature flags or feature branches? Both are valid. Feature flags let you ship incomplete code safely (the flag hides it). Feature branches isolate development but require merge management. Trunk-based development with feature flags scales better for large teams.
Can I do CI/CD without Docker?
Yes. CI/CD is the process, not the technology. Many pipelines zip and upload binaries, deploy Python with git pull + pip install, or use platform-specific tools (Heroku slug compiler, Vercel build). Docker is popular because it ensures environment consistency, not because it's required.
How do I handle database migrations in CI/CD? Run migrations before starting the new app version. In Kubernetes, use an init container or a pre-deployment job. Always make migrations backward compatible (add columns before removing old ones) so you can roll back the code without rolling back the schema.
What's GitOps?
GitOps means using Git as the single source of truth for your infrastructure and deployments. Instead of running kubectl apply in a pipeline, you commit YAML to a Git repo and a tool like Argo CD or Flux detects the change and applies it to the cluster. The running state always matches what's in Git.