ci/cd, taken apart

An illustrated guide

How CI/CD works,
on one workbench

Continuous integration and continuous delivery are a production line for software. Every change is built, tested, packaged, deployed and watched by the same machine, the same way, every time. Push a commit below and follow it down the line.

A bench-top line stands in for a pipeline. A blank is a commit; the press is the build; the gauge is the test suite; a labelled crate is the versioned artifact; the crane deploys it to staging and then to the idle production bay; the lever is the router; the dial watches the error rate. Every lamp, reading and version comes from one model of the run. Time is compressed: a real pipeline takes minutes, not seconds.

Why a production line?

Before CI/CD, releases were assembled by hand: someone built the code on their machine, someone else ran the tests (sometimes), and a third person copied files onto servers late at night. Every step could go a little differently each time.

A pipeline replaces that with a machine that does the same thing in the same order for every single change. Stations can't be skipped, a bad part can't sneak past the gauge, and every crate on the shelf is labelled with exactly where it came from. The rest of this page walks through the eight stations. After them come the topics a real line also has to handle: deployment strategies, database changes, security and measurement.

01

Commit

On the line: The magazine and its gate

Everything starts with a change pushed to the shared repository. The push is an event, and the CI system is subscribed to it: the repository host emits an event (a webhook, for an external CI server), and a pipeline run starts for that exact commit.

In most teams the first run happens before the merge. Opening a pull request triggers the build and tests on the proposed change, and branch protection refuses the merge until those required checks are green. The run on main after the merge then carries the change the rest of the way down the line.

On the line, each blank in the magazine is a commit. The gate lifts, one blank drops onto the belt, and the run gets a number and the commit's short hash. That hash travels with the part all the way down the line, so every result can be traced back to the source that produced it.

In real pipelinesgit push → a push or pull_request event → the CI system queues a run for commit a1f3c9e. Required status checks and branch protection rules gate the merge.

Small, frequent commits keep each run fast and make a failure easy to pin on one change. That is the “integration” in continuous integration: everyone merges into main often (trunk-based development), and the machine checks every merge.

02

Build

On the line: The press

The build turns source into something that can run: it fetches dependencies, compiles, bundles and minifies. Like a press, it should be repeatable. The same blank under the same die always gives the same part. (Bit-for-bit reproducible builds go further and also pin timestamps, toolchains and file ordering. Most teams aim for repeatable and get there with lockfiles and pinned images.)

The build runs on a clean, ephemeral runner every time, never on someone's laptop. That makes “works on my machine” a problem the pipeline catches, not one your users find. Caches for dependencies and build layers keep a clean build fast without making it dirty.

In real pipelinesnpm ci && npm run build, mvn package, go build ./..., cargo build --release; dependency caching with actions/setup-node (cache: npm) or Docker layer cache.

Pin everything the build pulls in: dependencies with a lockfile (npm ci fails if it doesn't match package.json), base images by digest, and CI actions by version or commit SHA. Then next month's rebuild of the same commit gives the same output.

03

Test

On the line: The gauge bridge, the kicker and the reject bin

Automated tests are the gauge. The probe drops onto the part and the dial reads it against a tolerance, here 13.00 ± 0.20 mm. Real suites check thousands of these tolerances: unit tests, integration tests, type checks, linters and security scans.

Suites are usually shaped like a pyramid: many fast unit tests at the base, fewer integration tests that exercise real dependencies (a database in a container, for example), and a thin layer of slow end-to-end tests on top. Static checks such as linting, type checking and security scanning run alongside them, often in parallel jobs.

When a reading is out of tolerance, the kicker throws the part into the reject bin and the line stops for that commit. Nothing downstream runs, no artifact is made, and the version already in production stays live. A red build blocks the change, never the users.

In real pipelinesnpm test, pytest, go test ./...; plus eslint, tsc --noEmit, dependency audits (npm audit, Dependabot), SAST (CodeQL, Semgrep) and secret scanning.

Fail fast: run the quickest checks first so a broken commit is rejected in seconds. A flaky test (one that sometimes fails for no reason) is a gauge you can't trust. Fix it or quarantine it, and never “retry until green”.

04

Package

On the line: The crate, the dispenser and the labeler

A part that passes is packed into a crate and labelled with a version. That crate is the artifact: a container image, a .jar, a zip or a binary. It must be treated as immutable. Once it is labelled, nobody opens it again.

Immutability has to be enforced, though. A container tag like app:1.4.2 is only a movable pointer, and anyone with push rights can point it at a different image. The image's digest (app@sha256:…) is a hash of its content and can never change. Pipelines deploy by digest, or turn on tag immutability in the registry.

This is the most important habit in delivery: build once, deploy many. The same crate goes to staging and then to production. Nothing is rebuilt between environments, so what you tested is exactly what you ship. What changes between environments is configuration (URLs, credentials, feature flags), which is injected at deploy time and never baked into the crate.

In real pipelinesdocker build -t registry/app:1.4.2 . then docker push, which prints the sha256 digest; or upload to an artifact store (npm, Maven, S3). Registries such as ECR, GHCR and Artifactory can reject overwrites of existing tags.

Tag artifacts with something you can trace: a semantic version, the commit hash, or both, and record the digest. Version numbers are never reused, even for a release that was rolled back.

05

Staging

On the line: The staging table and its test tower

The crane is the deployment tool: it moves crates and never changes what's inside. Its first stop is staging, an environment built like production but with no real users on it.

There the artifact gets smoke tests and end-to-end checks against real databases, real networking and real configuration. Bugs that only appear when the pieces run together show up here, including database migrations, which should run in staging before they ever touch production.

Staging uses its own configuration and secrets, injected by the deployment. The crate is the same one production will get; only the settings around it differ.

In real pipelinesdeploy.sh staging app@sha256:… then smoke-test.sh https://staging.example.com; environments with their own secrets in GitHub Actions, GitLab or Argo CD; short-lived preview environments per pull request.

Keep staging as close to production as you can afford: same infrastructure code, same migrations, a realistic data shape. Every difference between them is a place where a bug can hide.

06

Deploy

On the line: Two production bays and the router lever

Production has two bays, often called blue and green. One is live and the lever (a load balancer, router or service selector) sends all traffic to it. The crane retires whatever was sitting in the idle bay, sets the new crate there, waits for its health checks to pass, and only then does the lever move: new requests switch in one step, and requests already in flight on the old bay are allowed to finish (connection draining), so users see no downtime.

The old version stays in its bay, warm and ready. That is what makes the next step cheap. It also means both versions may briefly run side by side against the same database, so every change has to be backward compatible with the version before it.

Blue/green is one strategy among several. Choosing a deployment strategy, after the stations, compares them.

In real pipelinesBlue/green: AWS CodeDeploy, Azure App Service slots, Argo Rollouts. Rolling updates: Kubernetes Deployment (pods are replaced a few at a time). Canary: Argo Rollouts, Flagger, weighted load-balancer rules (1 %, 10 %, 50 %, 100 % of traffic).

Continuous delivery means every green run produces a crate that is ready to ship, and a person presses the button. Continuous deployment means the line ships it with no one pressing anything.

07

Monitor

On the line: The error-rate dial

Shipping isn't the end of the pipeline. The dial watches the live bay's error rate under real traffic, with a red mark at the threshold the team agreed on. Here that threshold is 1 % of requests failing.

Some bugs only appear under real load, real data or real users. The tests passed and staging looked fine, but the dial climbs anyway. Watching for a few minutes after every deploy is how you find out.

Teams usually watch the four golden signals: latency, traffic, errors and saturation. They compare them with a service level objective (SLO), such as “99.9 % of requests succeed over 30 days”. In mature pipelines a machine reads the dial: automated analysis compares the new version with the old one and triggers the rollback itself.

In real pipelinesError rate, latency (p95/p99), traffic, saturation; dashboards and alerts in Prometheus + Grafana, Datadog or CloudWatch; OpenTelemetry traces; automated canary analysis in Argo Rollouts or Flagger.

Decide the threshold before you deploy, not while the dial is climbing. Tag every metric and log with the version, so a spike points straight at the release that caused it.

08

Rollback

On the line: The lever, back the other way

When the dial crosses the red mark, the lever flips back to the previous bay. New requests go to the last good version as soon as the router switches (seconds with a load balancer, longer if the switch depends on DNS caches expiring), and nothing had to be rebuilt or redeployed. The faulty crate sits idle until the next deploy retires it.

The lever only moves code, not data. If the release changed the database schema or wrote data in a new format, flipping back is safe only when the old version can still read it. That is why migrations are made backward compatible (see below).

Then the fix goes through the whole line again as a new commit, with a new version number. A rollback undoes a deployment, not history. When a rollback isn't possible, teams roll forward instead: they ship a small fix quickly through the same line.

In real pipelinesswitch-traffic.sh previous, kubectl rollout undo deployment/app, argo rollouts abort, a feature flag turned off, or redeploying the last good digest.

A rollback you have never practised is not a plan. Automate it, and run it on purpose now and then. Feature flags give you a second, finer lever: turn off one feature without moving any traffic.

The same line, written as code

Real pipelines are defined in a file in the repository, so changes to the line itself are reviewed and versioned like any other code. Here is the whole bench as a GitHub Actions workflow. The numbered comments match the stations above. GitLab CI, Jenkins, CircleCI and Azure Pipelines all express the same idea in their own syntax.

name: ci-cd
on:
  pull_request:                      # 01 commit: every pull request is built and tested before merge
  push:
    branches: [main]                 # 01 commit: every push to main runs the whole line

permissions:
  contents: read                     # least privilege for the built-in GITHUB_TOKEN

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write                # needed only to push the image
    outputs:
      image: ghcr.io/${{ github.repository }}@${{ steps.image.outputs.digest }}
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci                  # 02 build: exact versions from the lockfile
      - run: npm run build
      - run: npm test                # 03 test: a failure stops the run here
      - if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - if: github.event_name == 'push'  # 04 package: pull requests stop after the tests
        id: image
        uses: docker/build-push-action@v6
        with:
          context: .                 # the Dockerfile copies in the tested build output
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  staging:
    needs: build-and-test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: staging             # staging's own config and secrets
    steps:                           # 05 staging: the same artifact by digest, not a rebuild
      - uses: actions/checkout@v5
      - run: ./deploy.sh staging ${{ needs.build-and-test.outputs.image }}
      - run: ./smoke-test.sh https://staging.example.com

  production:
    needs: [build-and-test, staging]
    runs-on: ubuntu-latest
    environment: production          # required reviewers here = continuous delivery; none = continuous deployment
    concurrency:
      group: production
      cancel-in-progress: false      # one deploy at a time; never interrupt one halfway
    steps:
      - uses: actions/checkout@v5
      - run: ./deploy.sh production ${{ needs.build-and-test.outputs.image }} --slot idle  # 06 deploy to the idle bay
      - run: ./switch-traffic.sh idle                               #    move the lever
      - run: ./watch.sh --max-error-rate 1% --for 10m               # 07 monitor
      - if: failure()
        run: ./switch-traffic.sh previous                           # 08 roll back

Things to notice: pull requests run only the build and tests, while pushes to main go all the way. The token gets only the permissions each job needs. The image is passed between jobs by digest, never rebuilt. The production environment is where you add required reviewers (continuous delivery) or leave them out (continuous deployment). The concurrency group makes sure two deploys never move the lever at the same time. The *.sh scripts stand in for your platform's deploy tooling.

Choosing a deployment strategy

The line uses blue/green because it is the easiest to see. Real teams choose by how much downtime, cost and risk they can accept. Many combine them, for example a canary on top of a rolling update, with feature flags for risky features.

StrategyHow it worksDowntimeRollbackExtra cost
RecreateStop the old version, start the new one.Yes, while it restartsRedeploy the old versionNone
Rolling updateReplace instances a few at a time.No, if health checks gate each batchAnother rollout, in reverseSmall (a surge of a few instances)
Blue/greenDeploy to an idle copy, then switch all traffic.NoFlip the router back: secondsDouble, during the deploy
CanarySend 1 %, then 10 %, 50 %, 100 % of traffic to the new version while metrics stay healthy.NoSend the canary's traffic back: seconds, and few users affectedSmall; needs good metrics
Feature flagsShip the code switched off, then turn the feature on for chosen users.NoTurn the flag off: no deploy at allFlag service and cleanup of old flags

The part the lever can't move: databases

Blue and green bays share one database. During a deploy, and again after a rollback, the old and the new version both talk to it. A migration that drops or renames a column breaks whichever version doesn't expect it, and no lever can undo that.

The safe pattern is expand/contract (also called parallel change), spread over several releases:

  1. Expand: add the new column or table. Old code ignores it.
  2. Migrate: ship code that writes to both and reads from the new one, then backfill existing rows.
  3. Contract: once no running version uses the old column, remove it in a later release.

Migrations are versioned files in the repository (Flyway, Liquibase, Alembic, Prisma Migrate, Rails migrations). They run as their own step before the new code goes live, and they are tested in staging first.

Securing the line

A pipeline holds the keys to production, so it is a favourite target. If someone can tamper with the line, they can ship anything. The common safeguards:

Measuring the line

How do you know whether the pipeline is getting better? Google's DORA research program tracks four metrics that, together, measure both speed and stability:

Deployment frequency
How often a change reaches production.
Change lead time
The time from a commit to that commit running in production.
Change fail rate
The share of deployments that cause a failure needing a rollback or fix.
Failed deployment recovery time
How long it takes to recover from a failed deployment (formerly reported as mean time to restore).

The research consistently finds that speed and stability are not a trade-off: teams that deploy small changes often also tend to fail less and recover faster. That is the whole argument for the line.

Six rules the line follows

  1. Keep main green. A failing run is the team's top priority; nobody stacks new work on a broken line.
  2. Small batches. One small change per run makes failures quick to find and cheap to fix.
  3. Build once, deploy many. The artifact that passed staging, identified by its digest, is the artifact that reaches production. Only configuration changes between environments.
  4. Everything as code. The pipeline, the infrastructure, the configuration and the database migrations live in the repository.
  5. Always have a way back. Keep the previous version ready, keep schema changes backward compatible, and make rollback a single, rehearsed action.
  6. Guard the line itself. Short-lived credentials, least-privilege tokens and pinned dependencies, because whoever controls the pipeline controls production.

Check yourself

A test fails on commit 28c629c. What happens to production?

Nothing. The kicker rejects the part at the test station, no artifact is built, and the version already live keeps serving users.

Why does the crane carry the same crate from staging to production instead of building a new one?

Build once, deploy many. Rebuilding could produce something slightly different from what was tested; reusing the artifact guarantees production runs exactly what passed staging.

The new version passed every test, but errors climb after the switch. Why is the rollback so fast?

Because the previous version is still running in the other bay. Rolling back is moving the lever, not rebuilding or redeploying.

What's the difference between continuous delivery and continuous deployment?

Both keep every green build releasable. Delivery waits for a person to press the button for production; deployment ships automatically.

Staging tested app:1.4.2. Why should production deploy app@sha256:… instead of the tag?

A tag is a movable pointer, and it could have been re-pushed between staging and production. The digest is a hash of the content, so it guarantees production gets the exact image that was tested.

A release renamed a database column, and errors climb. Why might flipping the lever back not fix it?

The old version still expects the old column name. Rollback moves code, not data, so schema changes have to be backward compatible: add the new column, migrate and dual-write, and remove the old one only in a later release.

Glossary

Continuous integration (CI)
Everyone merges small changes into the main branch often, and every merge is built and tested automatically.
Continuous delivery
Every change that passes the pipeline is packaged and ready to release; a person decides when it goes to production.
Continuous deployment
Every change that passes the pipeline goes to production automatically, with no manual step.
Pipeline
The ordered stages a commit passes through: build, test, package, deploy. Usually defined as code in the repository.
Artifact
The built, versioned, immutable output of a run: an image, a package or a binary. The crate.
Environment
A place the artifact runs: development, staging, production. Each has its own configuration and secrets.
Blue/green deployment
Two identical production slots; deploy to the idle one, then switch traffic. The old one stays ready for a rollback.
Canary release
Send a small share of traffic to the new version first, and widen it only while the metrics stay healthy.
Rollback
Returning production to the last good version, ideally by flipping traffic rather than rebuilding.
Flaky test
A test that passes and fails on the same code. It erodes trust in the gauge and should be fixed or quarantined.
Pull request (merge request)
A proposed change that CI builds and tests before it is merged. Branch protection can require those checks to pass.
Trunk-based development
Everyone integrates into one main branch at least daily, using short-lived branches and feature flags instead of long-running branches.
Image digest
The sha256 hash of a container image's content. Unlike a tag, it can never point at different content.
Smoke test
A quick check that a freshly deployed system starts and answers its most important requests. It isn't a full test suite.
Rolling update
Replacing instances of the old version with the new one a few at a time. It needs no spare capacity, but rolling back is another rollout.
Feature flag
A runtime switch that turns a feature on or off without a deploy. It separates deploying code from releasing a feature.
Expand/contract migration
Changing a database schema in backward-compatible steps (add, migrate, then remove) so old and new versions can run side by side.
Infrastructure as code (IaC)
Servers, networks and clusters described in versioned files (Terraform, OpenTofu, Pulumi, CloudFormation) and changed through the same review and pipeline as application code.
GitOps
Infrastructure as code where a controller (Argo CD, Flux) keeps the cluster in sync with a Git repository, so a deploy is a commit.
SLO
Service level objective: a target for a reliability measure, such as 99.9 % of requests succeeding over 30 days. It sets the thresholds the monitor watches.
OIDC federation
Letting a CI job trade a short-lived identity token for cloud credentials, so no long-lived keys are stored as secrets.
SBOM
Software bill of materials: a list of every component and version inside an artifact, used to find affected releases when a vulnerability is announced.
DORA metrics
Four measures of delivery performance: deployment frequency, change lead time, change fail rate and failed deployment recovery time.