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.
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.
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.
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”.
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.
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.
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.
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.
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.
| Strategy | How it works | Downtime | Rollback | Extra cost |
|---|---|---|---|---|
| Recreate | Stop the old version, start the new one. | Yes, while it restarts | Redeploy the old version | None |
| Rolling update | Replace instances a few at a time. | No, if health checks gate each batch | Another rollout, in reverse | Small (a surge of a few instances) |
| Blue/green | Deploy to an idle copy, then switch all traffic. | No | Flip the router back: seconds | Double, during the deploy |
| Canary | Send 1 %, then 10 %, 50 %, 100 % of traffic to the new version while metrics stay healthy. | No | Send the canary's traffic back: seconds, and few users affected | Small; needs good metrics |
| Feature flags | Ship the code switched off, then turn the feature on for chosen users. | No | Turn the flag off: no deploy at all | Flag 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:
- Expand: add the new column or table. Old code ignores it.
- Migrate: ship code that writes to both and reads from the new one, then backfill existing rows.
- 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:
- No long-lived secrets. Use OIDC federation so each job gets short-lived cloud credentials. Keep any remaining secrets in the CI secret store, scoped to one environment, and never in the repository.
- Least privilege. Give the CI token read-only access by default and grant write access only to the job that needs it. Protect the production environment with required reviewers or branch rules.
- Pin what you run. Third-party actions, base images and tools can change under you. Pin them by version or, better, by commit SHA or digest, and let Dependabot or Renovate propose updates.
- Scan early (“shift left”). Dependency scanning, static analysis (SAST), secret scanning and image scanning run at the test station, where a finding stops the line like any other failed check.
- Prove where the crate came from. Generate an SBOM, sign the artifact (Sigstore/cosign) and attach build provenance (the SLSA framework), so the cluster can refuse anything this pipeline didn't build.
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
- Keep main green. A failing run is the team's top priority; nobody stacks new work on a broken line.
- Small batches. One small change per run makes failures quick to find and cheap to fix.
- Build once, deploy many. The artifact that passed staging, identified by its digest, is the artifact that reaches production. Only configuration changes between environments.
- Everything as code. The pipeline, the infrastructure, the configuration and the database migrations live in the repository.
- Always have a way back. Keep the previous version ready, keep schema changes backward compatible, and make rollback a single, rehearsed action.
- 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
sha256hash 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.