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.
| Stage | Purpose | Typical tools | Fails on |
|---|---|---|---|
| 1. Lint / static | Catch obvious mistakes in seconds | shellcheck, hadolint, eslint, ruff, gitleaks | Syntax, style, committed secrets |
| 2. Build | Produce one immutable artifact | docker build, go build, mvn package | Compile errors, missing deps |
| 3. Test | Prove behaviour against the artifact | unit tests, integration tests with services | Regressions |
| 4. Scan | Known vulnerabilities and misconfig | trivy, grype, dependency audit | HIGH/CRITICAL CVEs, policy |
| 5. Publish | Push the artifact, record its digest | registry push, package upload | Auth, registry errors |
| 6. Deploy staging | Run the exact artifact in a prod-like env | kubectl, helm, ansible, terraform | Config drift, migrations |
| 7. Verify | Smoke tests against the live service | curl health checks, synthetic tests | Broken endpoints |
| 8. Deploy production | Gated, serialized, observable rollout | manual approval, canary, rolling update | Health 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.
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
| Strategy | How it works | Rollback speed | Cost |
|---|---|---|---|
| Rolling update | Replace pods a few at a time | Minutes (another rollout) | No extra capacity |
| Blue/green | Run old and new side by side, switch traffic | Seconds (switch back) | Double capacity during deploy |
| Canary | Send a small share of traffic to the new version first | Seconds (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.