Skip to content
DevOps devops cicd 6 min read

CI/CD Best Practices

A CI/CD pipeline (CI/CD means Continuous Integration / Continuous Delivery — the automation that builds, tests, and ships your code every time you push) is only as good as the habits behind it. A slow, flaky, or insecure pipeline trains your team to ignore it, click “re-run”, or bypass it entirely — and that is how broken code reaches production. This page is a practical checklist of the practices that separate a pipeline people trust from one they fight. Each item explains the “why”, when to apply it, and the exact commands or config to use on Ubuntu (Ubuntu 22.04/24.04 LTS).

Keep pipelines fast

A pipeline that takes 30 minutes kills momentum. Developers context-switch, stop watching, and stack up un-merged branches. Aim for under 10 minutes to first feedback.

The biggest wins come from doing less work and doing it in parallel. Split your pipeline into independent jobs (lint, unit tests, integration tests) so they run at the same time instead of one after another.

# .github/workflows/ci.yml — jobs run in parallel by default
jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

When to use this: always. When not to: do not parallelise jobs that depend on each other’s output — use needs: to order those.

Fail fast

Fail fast means surfacing the cheapest, most likely error first, and stopping the moment something breaks. If linting takes 10 seconds and the test suite takes 8 minutes, run linting first. There is no point spending 8 minutes only to fail on a missing semicolon.

In shell scripts, fail fast is one line. Put this at the top of every CI script:

#!/usr/bin/env bash
set -euo pipefail
# -e  exit on first error
# -u  error on undefined variables
# -o pipefail  a failed command in a pipe fails the whole pipe

Without set -e, a script keeps running after a command fails and can report “success” on a broken build.

Gotcha: the default shell behaviour is to ignore errors mid-script. A cp that silently fails, followed by a deploy, ships an empty artifact. set -euo pipefail prevents this. Make it the first line, every time.

Make builds reproducible

Reproducible means: the same commit produces the same output, today and in six months, on your laptop and in CI. The enemy is “works on my machine”. The fix is to pin everything and never rely on whatever happens to be installed on the runner.

Use lockfiles and the “clean install” command, not the everyday install command:

# Node — uses package-lock.json exactly, never updates it
npm ci

# Python — install exact pinned versions
pip install -r requirements.txt --require-hashes

npm ci deletes node_modules and installs the exact versions in the lockfile. npm install may quietly upgrade packages, which makes builds drift over time.

CommandBehaviourUse in CI?
npm installMay update the lockfileNo
npm ciInstalls the lockfile exactly, fails if out of syncYes
pip install pkgResolves latest compatibleNo
pip install -r requirements.txtInstalls pinned fileYes

Pin versions

Floating versions (latest, v4, *) make your pipeline change without you changing anything — a surprise upgrade can break a build with zero commits. Pin runner images, actions, base images, and tools to exact versions.

# Pin the OS image and the action by full SHA, not a moving tag
jobs:
  build:
    runs-on: ubuntu-24.04            # not "ubuntu-latest"
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# Pin the base image by digest so it can never silently change
FROM node:22.11.0-bookworm-slim@sha256:abc123...

When to use this: for anything that runs in production builds. When not to: in a throwaway prototype, floating tags save time — just never do it for release pipelines.

Cache wisely

Caching reuses downloaded dependencies between runs so you do not re-download the internet every time. Cache the right thing — your dependency folder — keyed on your lockfile, so the cache busts automatically when dependencies change.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}
    restore-keys: npm-

The key includes a hash of the lockfile. Change a dependency, the hash changes, you get a fresh install. Otherwise you reuse the cache. When not to cache: never cache build output you are about to test — a stale cache can hide real failures.

Secure your secrets

A secret (a password, API token, or SSH key) must never appear in your repository, your logs, or your code. Store secrets in your CI provider’s encrypted secret store and read them as environment variables at runtime.

steps:
  - run: ./deploy.sh
    env:
      DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

On a self-hosted Ubuntu runner, never echo secrets and lock down any files that hold them:

sudo install -m 600 -o ci -g ci /dev/stdin /etc/ci/deploy.env <<'EOF'
DEPLOY_KEY=...
EOF

Output:

$ ls -l /etc/ci/deploy.env
-rw------- 1 ci ci 64 Jun 15 10:22 /etc/ci/deploy.env

Mode 600 means only the owner can read the file. Add a secret scanner to your pipeline to catch accidental commits:

sudo apt update && sudo apt install -y gitleaks
gitleaks detect --source . --no-banner

Security tip: rotate any secret the moment it touches a log or a screen share. Treat a leaked secret as already compromised — change it, do not just delete the message.

Build one artifact, promote it across stages

An artifact is the single packaged output of your build — a Docker image, a .jar, a tarball. Build it once, then promote that exact same artifact through staging and production. If you rebuild for each environment, you are testing one thing and shipping another.

# Build once, tag with the immutable commit SHA
docker build -t registry.example.com/app:${GIT_SHA} .
docker push registry.example.com/app:${GIT_SHA}

# Later stages re-tag the SAME image — they never rebuild
docker pull registry.example.com/app:${GIT_SHA}
docker tag  registry.example.com/app:${GIT_SHA} registry.example.com/app:prod
docker push registry.example.com/app:prod

Using the commit SHA as the tag makes every deploy traceable back to exact source code.

Treat your pipeline as code

Pipeline-as-code means your build and deploy steps live in a versioned file in the repo (.github/workflows/, .gitlab-ci.yml, or a Jenkinsfile) — not clicked into a web UI. This gives you review, history, rollback, and the ability to test changes the same way you test app code.

Keep that file in the repo, require pull-request review for changes to it, and validate it before merging:

# Lint a GitHub Actions workflow locally before pushing
sudo apt install -y actionlint || curl -fsSL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz | sudo tar -xz -C /usr/local/bin actionlint
actionlint .github/workflows/ci.yml

Output:

$ actionlint .github/workflows/ci.yml
(no output — workflow is valid)

Best Practices

  • Run independent jobs in parallel and order them cheapest-first so failures surface in seconds, not minutes.
  • Start every CI shell script with set -euo pipefail so a single failed command stops the build.
  • Use lockfiles and clean-install commands (npm ci, hashed pip install) so the same commit always builds the same output.
  • Pin runner images, actions, and base images to exact versions or digests — never latest.
  • Store every secret in an encrypted secret store, scan for leaks with gitleaks, and rotate on any exposure.
  • Build one artifact tagged by commit SHA and promote that same artifact to every environment.
  • Keep the pipeline definition in the repo, review it through pull requests, and lint it with actionlint before merge.
Last updated June 15, 2026
Was this helpful?