Skip to content

CI/CD Pipeline Design: The Stages Every Deployment Needs

Design a CI/CD pipeline that fails fast and deploys safely: lint, build once, test, scan, publish by digest, staged rollout, smoke tests, and a rollback that works.

●By 11 min read

Most pipelines are not designed. They grow: someone adds a test job, someone else bolts on a deploy script, a scanner appears after an audit. A year later the pipeline takes forty minutes, rebuilds the image three times, and nobody is sure whether the thing in production is the thing that passed the tests.

A good pipeline answers two questions on every commit: is this change safe to ship, and can we get it out and back again without drama. The stages below are the minimum set that answers both. The tool does not matter much; the examples use GitLab CI and GitHub Actions, but the same order works in Jenkins, Woodpecker or Buildkite.

The Stage Map: Cheap Checks First

Order stages by cost and by how likely they are to fail. A formatting error should be reported in thirty seconds, not after a fifteen-minute integration suite. Everything up to publish runs on every branch; everything after it runs only on the main branch or a release tag.

StagePurposeTypical toolsFails on
1. Lint / staticCatch obvious mistakes in secondsshellcheck, hadolint, eslint, ruff, gitleaksSyntax, style, committed secrets
2. BuildProduce one immutable artifactdocker build, go build, mvn packageCompile errors, missing deps
3. TestProve behaviour against the artifactunit tests, integration tests with servicesRegressions
4. ScanKnown vulnerabilities and misconfigtrivy, grype, dependency auditHIGH/CRITICAL CVEs, policy
5. PublishPush the artifact, record its digestregistry push, package uploadAuth, registry errors
6. Deploy stagingRun the exact artifact in a prod-like envkubectl, helm, ansible, terraformConfig drift, migrations
7. VerifySmoke tests against the live servicecurl health checks, synthetic testsBroken endpoints
8. Deploy productionGated, serialized, observable rolloutmanual approval, canary, rolling updateHealth checks, error rate

Fail fast, fail cheap β€” if a stage takes more than a few minutes, split it so the fast part runs first, or run independent jobs in parallel inside the stage.

One pipeline definition β€” keep it in the repository next to the code, so a change to the build and the change that needs it land in the same review.

Lint and Build: Build Exactly Once

The single most important rule in pipeline design is build once, deploy many. The artifact that passes tests must be the byte-identical artifact that reaches staging and production. Rebuilding per environment means you test one binary and ship another, with whatever the package mirrors served five minutes later.

# .gitlab-ci.yml

stages: [lint, build, test, scan, publish, staging, production]

variables:

IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

lint:

stage: lint

image: alpine:3.20

script:

- apk add --no-cache shellcheck

- shellcheck --severity=warning scripts/*.sh

build:

stage: build

image: docker:27

services: [docker:27-dind]

script:

- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"

- docker build --pull --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" -t "$IMAGE" .

- docker push "$IMAGE"

Tag by commit, not by branch β€” a tag like :main or :latest moves; :3f9c2ab does not. Every later stage refers to the commit tag, and you can always trace a running container back to its source.

Configuration is not the artifact β€” environment differences belong in runtime config β€” environment variables, mounted files, secrets β€” never in build arguments that bake staging URLs into the image.

Pin build tooling β€” use docker:27 rather than docker:latest and pin base images in the Dockerfile. Unpinned tooling is the most common reason yesterday's green pipeline is red today with no code change. Our Dockerfile generator produces pinned multi-stage builds.

Pushing before tests is fine β€” the commit-tagged image is just a candidate. What matters is that only images that passed later stages get promoted or deployed.

Test and Scan Against the Artifact

Tests should run against the image you built, not against a fresh checkout with a different dependency tree. Integration tests get real dependencies as service containers, so a database migration is exercised before it meets production data.

unit:

stage: test

image: $IMAGE

script:

- /app/bin/run-tests --junit report.xml

artifacts:

when: always

reports:

junit: report.xml

integration:

stage: test

image: $IMAGE

services:

- name: postgres:16

alias: db

variables:

POSTGRES_PASSWORD: test

DATABASE_URL: postgres://postgres:test@db:5432/postgres

script:

- /app/bin/migrate up

- /app/bin/run-integration-tests

scan:

stage: scan

image:

name: aquasec/trivy:latest

entrypoint: [""]

variables:

TRIVY_USERNAME: $CI_REGISTRY_USER

TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD

script:

- trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$IMAGE"

--exit-code 1 β€” without it Trivy reports findings and exits 0, so the job always passes. --ignore-unfixed keeps the gate to vulnerabilities that actually have a patched version available.

Keep test reports on failure β€” artifacts: when: always uploads the JUnit file even when tests fail, which is exactly when you need it.

Flaky tests are pipeline bugs β€” a test that fails one run in twenty trains people to click retry. Quarantine it and fix it; do not add automatic retries to the test stage.

Scan the repo too β€” run a secret scanner such as gitleaks detect --source . in the lint stage. A leaked key in history needs rotation, not just a revert.

sponsored

Publish and Promote by Digest

Tags are mutable pointers; digests are content hashes. When an image passes every gate, record its digest and deploy that. Promotion then means adding a human-friendly tag to an image that already exists, never building a new one.

publish:

stage: publish

image: docker:27

services: [docker:27-dind]

rules:

- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

script:

- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"

- docker pull "$IMAGE"

- docker tag "$IMAGE" "$CI_REGISTRY_IMAGE:main"

- docker push "$CI_REGISTRY_IMAGE:main"

- docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE" | tee digest.txt

artifacts:

paths: [digest.txt]

# digest.txt now holds something like:

# registry.example.com/team/app@sha256:9b2f4c...e71a

Restrict who can push β€” only the pipeline should be able to write to the production repository or tag pattern. If engineers can push images from laptops, the build-once guarantee is gone.

Retention β€” keep every image that was ever deployed to production for at least as long as you might need to roll back to it. Clean up untested branch images aggressively.

Staging, Smoke Tests and the Production Gate

Deployment jobs need three properties: they deploy the recorded digest, they never run concurrently against the same environment, and they wait for the rollout to actually succeed instead of firing a command and reporting green.

deploy-staging:

stage: staging

image: bitnami/kubectl:1.31

environment: { name: staging, url: https://staging.example.com }

resource_group: staging

rules:

- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

script:

- kubectl -n app set image deployment/web web="$(cat digest.txt)"

- kubectl -n app rollout status deployment/web --timeout=180s

- curl -fsS --retry 5 --retry-delay 3 --retry-all-errors https://staging.example.com/healthz

deploy-production:

stage: production

image: bitnami/kubectl:1.31

environment: { name: production, url: https://example.com }

resource_group: production

rules:

- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

when: manual

script:

- kubectl -n app set image deployment/web web="$(cat digest.txt)"

- kubectl -n app rollout status deployment/web --timeout=300s || { kubectl -n app rollout undo deployment/web; exit 1; }

The same shape in GitHub Actions uses environments for the approval gate and concurrency for serialization. Required reviewers are configured on the environment in the repository settings, not in the YAML:

deploy-production:

needs: [build, deploy-staging]

runs-on: ubuntu-latest

environment: production # required reviewers set in repo settings

concurrency:

group: deploy-production

cancel-in-progress: false # never kill a half-finished rollout

steps:

- run: kubectl -n app set image deployment/web web=${{ needs.build.outputs.digest }}

- run: kubectl -n app rollout status deployment/web --timeout=300s

resource_group / concurrency β€” two pipelines deploying to production at the same time is how an older commit ends up live after a newer one. Serialize per environment.

rollout status is the real deploy step β€” kubectl set image returns as soon as the API accepts the change. rollout status blocks until new pods are ready or the timeout expires, and returns non-zero on failure. Readiness probes in your manifests decide what "ready" means β€” see the Kubernetes generator.

Smoke tests, not test suites β€” after deploy, hit a handful of critical endpoints: health, login, one read, one write. The goal is detecting a broken deploy in under a minute, not re-running the test stage.

curl -f matters β€” without -f, curl exits 0 on HTTP 500. --retry-all-errors (curl 7.71+) also retries on HTTP errors and refused connections while pods are still starting.

Rollback, Migrations and Observability

A pipeline without a tested rollback path is a one-way door. Because every deployed image is addressed by digest and retained, rolling back is just deploying the previous digest β€” the same job, with an older input.

# Kubernetes keeps ReplicaSet history per Deployment

$ kubectl -n app rollout history deployment/web

deployment.apps/web

REVISION CHANGE-CAUSE

14 <none>

15 <none>

$ kubectl -n app rollout undo deployment/web # back one revision

$ kubectl -n app rollout undo deployment/web --to-revision=14

$ kubectl -n app rollout status deployment/web

StrategyHow it worksRollback speedCost
Rolling updateReplace pods a few at a timeMinutes (another rollout)No extra capacity
Blue/greenRun old and new side by side, switch trafficSeconds (switch back)Double capacity during deploy
CanarySend a small share of traffic to the new version firstSeconds (drop the canary)Needs traffic splitting and metrics

Expand, then contract β€” database migrations must work with both the old and the new version of the code. Add the new column first, deploy code that uses it, and drop the old column in a later release. Otherwise rollback breaks on the schema.

Mark deploys in your metrics β€” send a deploy event with the commit SHA to your monitoring system at the end of the job. When error rates jump, the first question is always what changed.

Practise the rollback β€” run it in staging on a schedule. A rollback path that has never been exercised fails the first time you need it.

Pipeline scripts are code β€” deploy logic in shell deserves the same review as application code. Our guide to reviewing shell scripts covers the quoting and error-handling bugs that bite in CI, and Docker for Linux admins covers running the resulting images.

Frequently Asked Questions

What are the essential stages of a CI/CD pipeline?

At minimum: lint and static analysis, build a single artifact, test that artifact, scan it for vulnerabilities, publish it to a registry, deploy to staging, run smoke tests, and deploy to production through a gate. Order them so cheap, likely-to-fail checks run first.

Why should a pipeline build the artifact only once?

Rebuilding per environment means production runs something that was never tested: dependencies, base images and mirrors can change between builds. Building once and promoting the same image by digest guarantees that what passed the tests is what ships.

Should production deploys be automatic or manual?

Either works if the gates are real. Continuous deployment needs strong automated tests, health-checked rollouts and automatic rollback. If those are not in place yet, a manual approval on the production job is a sensible intermediate step, and it should still deploy the exact digest staging received.

How do I stop two deploys running at the same time?

Serialize jobs per environment. GitLab CI uses resource_group on the deploy job; GitHub Actions uses a concurrency group with cancel-in-progress set to false so a running rollout is never killed halfway. Without this, an older commit can finish after a newer one and end up live.

Where should vulnerability scanning happen in the pipeline?

Scan the built image after the build stage and before publishing or deploying, and fail the job on HIGH and CRITICAL findings that have a fix available. Secret scanning belongs in the first stage, since it only needs the source tree and catches leaks before anything is built.

sponsored

Related Tools

See also