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
cpthat silently fails, followed by a deploy, ships an empty artifact.set -euo pipefailprevents 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.
| Command | Behaviour | Use in CI? |
|---|---|---|
npm install | May update the lockfile | No |
npm ci | Installs the lockfile exactly, fails if out of sync | Yes |
pip install pkg | Resolves latest compatible | No |
pip install -r requirements.txt | Installs pinned file | Yes |
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 pipefailso a single failed command stops the build. - Use lockfiles and clean-install commands (
npm ci, hashedpip 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
actionlintbefore merge.