Continuous integration is the practice of merging all developer changes to a shared branch frequently and verifying each merge automatically. Continuous deployment is the practice of releasing every successful build to an environment without manual intervention. Together they compress the feedback loop from "code written" to "bug found" — or "feature shipped" — from days to minutes.
What changed in 2026
- GitHub Actions is the dominant CI platform for teams under 500 engineers. GitLab CI is strong for self-hosted. Circle CI and Travis CI have significant smaller followings. The concepts in this guide apply across all of them.
- Reusable workflows and composite actions matured. You can now share pipeline logic across repos without copying YAML.
- OIDC-based cloud auth replaced static secrets. AWS, GCP, and Azure all support GitHub Actions OIDC — your pipeline gets a short-lived token without storing long-lived credentials.
- AI-generated pipeline YAML is common. Copilot and similar tools generate decent starting points, but they often miss caching, environment protection rules, and security hardening. This guide covers what they miss.
Pipeline anatomy
A production CI/CD setup has at minimum:
push / PR → [CI] lint → test → build → scan
↓
[CD - staging] deploy → smoke test
↓ (manual approval or auto on main)
[CD - production] deploy → verify
GitHub Actions: a working example
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # built-in cache for npm
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
Dependency caching
Uncached installs are the main reason pipelines are slow. Always cache:
# Node: actions/setup-node with cache: 'npm' handles this automatically
# Python
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip'
# Docker layers (with BuildKit)
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
With caching, a typical Node.js install goes from ~90 seconds to ~5 seconds.
Secrets and environment variables
Use OIDC instead of static secrets for cloud deployments:
permissions:
id-token: write
contents: read
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1
# No static AWS_ACCESS_KEY or AWS_SECRET_KEY needed
For other secrets, use GitHub Encrypted Secrets:
- run: docker push myrepo/myapp:${{ github.sha }}
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
Never print secrets to logs. Accidentally logging ${{ secrets.X }} exposes it — GitHub masks known secret values but cannot mask transformed variants.
CD: deploying to environments
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging # GitHub Environment: sets protection rules
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: ./scripts/deploy.sh staging ${{ github.sha }}
- name: Smoke test
run: ./scripts/smoke-test.sh https://staging.myapp.com
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # GitHub Environment: requires manual approval
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh production ${{ github.sha }}
GitHub Environments let you add required reviewers, wait timers, and branch restrictions to each deployment target.
Pipeline speed benchmarks
| Stage |
Slow (no cache) |
Fast (optimised) |
| npm/pip install |
60–120 s |
5–10 s |
| Unit tests |
30–120 s |
10–30 s |
| Docker build |
120–300 s |
15–45 s (layer cache) |
| Total CI |
5–10 min |
2–4 min |
How to start from zero
- Add a basic lint + test job. Even one job that runs
npm test catches most regressions.
- Add dependency caching. Immediately cuts run time by 60–80%.
- Add a build step that produces an artifact.
- Add a staging deploy triggered on merge to main.
- Add a production deploy with a manual approval gate.
- Add security scanning (Trivy, CodeQL, or Dependabot) once the core pipeline is stable.
Common mistakes
No concurrency limits. Two simultaneous deploys to the same environment can corrupt state. Add concurrency: group: deploy-${{ github.ref }} to your deploy jobs.
Secrets in workflow file. Never hardcode credentials. They end up in git history even after removal.
Skipping the smoke test. Deploying without any post-deploy verification means a broken deploy sits until someone manually notices.
Not pinning action versions. uses: actions/checkout@main can break when the action updates. Pin to a SHA: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683.
Running all tests as one giant job. Split fast unit tests and slow integration tests into separate jobs. Fail the fast ones first.
What to skip
- Building your own CI runner hardware unless you have unusual compute requirements. GitHub-hosted runners are cost-effective for most teams.
- A pipeline that deploys on every branch push. Deploy on merge to main (or release branches). Deploying every feature branch creates environment chaos.
- Complex rollback pipelines before you need them. Start with simple redeploy-previous-sha; add blue/green or canary only when downtime is a real business problem.
FAQ
Should CI run on every commit or just PRs?
Both. Run on every PR push (fast feedback for the author) and on every merge to main (the canonical build).
How do I handle database migrations in CD?
Run migrations as a separate step before the new app version starts receiving traffic. Use a migration tool with built-in forward/backward safety (Flyway, Alembic, golang-migrate).
What if a deploy fails in production?
Have a documented rollback procedure: re-trigger the previous successful workflow run or deploy the previous artifact SHA. Test your rollback quarterly.
How do I debug a failing CI job?
Enable ACTIONS_RUNNER_DEBUG=true in GitHub Secrets. For SSH access to a failing runner, use mxschmitt/action-tmate as a last resort.
Where to go next