CI/CD Pipeline Best Practices
A CI/CD pipeline (Continuous Integration / Continuous Delivery — the automated process that builds, tests, and ships your code every time you push) is the backbone of modern software delivery. A good pipeline gives you confidence: when it goes green, you trust the change is safe to release. A bad pipeline is slow, flaky, and ignored — people start skipping it or merging despite red builds. This page is a practical checklist for building pipelines you can actually trust, with concrete steps you run on an Ubuntu (22.04 / 24.04 LTS) build server or in a hosted runner.
Keep pipelines fast
Speed is not a luxury. A pipeline that takes 40 minutes breaks the developer’s flow, so people batch changes, review less, and merge bigger risky chunks. Aim for the whole “push to green” loop to finish in under 10 minutes for most projects.
When to use this: always. The faster the feedback, the more often it runs and the more it catches.
The biggest win is caching dependencies so you do not re-download the internet on every run. On a self-hosted Ubuntu runner you can warm a cache directory and reuse it.
# Cache the npm download store between builds (Ubuntu runner)
sudo mkdir -p /var/cache/ci/npm
sudo chown "$USER":"$USER" /var/cache/ci/npm
npm config set cache /var/cache/ci/npm
npm ci
Output:
added 842 packages, and audited 843 packages in 6s
found 0 vulnerabilities
Notice npm ci instead of npm install. npm ci installs the exact versions locked in package-lock.json and errors if the lockfile is out of sync — that makes the install deterministic (the same result every time).
Other speed levers:
| Technique | What it does | When to use |
|---|---|---|
| Dependency caching | Reuse downloaded packages | Always |
| Parallel jobs | Run lint, unit tests, build at once | When stages are independent |
| Run only what changed | Skip tests for untouched modules | Large monorepos |
| Shallow git clone | Fetch only recent history | Big repos with long history |
Make it deterministic and fail fast
Deterministic means the pipeline gives the same answer for the same input — no “works on the second try”. The enemy of determinism is the flaky test (a test that passes and fails randomly without code changes). Quarantine flaky tests immediately; one flaky test trains the whole team to hit “re-run” and ignore real failures.
Fail fast means put the cheap, fast checks first so a broken build dies in seconds, not after a 20-minute deploy step. A sensible order:
- Lint and format check (seconds)
- Unit tests (fast)
- Build artifact
- Integration / end-to-end tests (slow)
- Security scan
- Deploy
Configure your runner so any failing step stops the pipeline. In a shell-driven job, set -e makes bash exit on the first error.
#!/usr/bin/env bash
set -euo pipefail # -e exit on error, -u error on unset var, -o pipefail catch piped failures
npm run lint
npm test
npm run build
Build once, promote the same artifact
This is the single most important rule. Build your artifact (the compiled output — a Docker image, a .jar, a tarball) exactly once, then promote that same artifact through staging and production. Never rebuild per environment. If you build separately for staging and prod, you tested one thing and shipped a different one.
# Build a versioned image ONCE, tagged with the git commit
TAG="$(git rev-parse --short HEAD)"
docker build -t registry.example.com/myapp:"$TAG" .
docker push registry.example.com/myapp:"$TAG"
# Later, promote the SAME tag — do not rebuild
docker pull registry.example.com/myapp:"$TAG"
docker tag registry.example.com/myapp:"$TAG" registry.example.com/myapp:production
docker push registry.example.com/myapp:production
Tag images with an immutable identifier like the git commit SHA. Avoid relying on
latestfor deploys —latestis a moving target and you can never prove which build is actually running.
Pin every version
Pinning means locking the exact version of everything: language runtimes, base images, actions, and dependencies. Unpinned versions mean a pipeline that passed yesterday can fail today because an upstream tool published a new release overnight.
| Thing | Bad (floating) | Good (pinned) |
|---|---|---|
| Docker base image | node:latest | node:22.14.0-bookworm |
| Apt package | apt install nginx | apt install nginx=1.24.0-2ubuntu* |
| Dependencies | ^1.2.0 only | committed package-lock.json |
| Pipeline action | actions/checkout@v4 | actions/checkout@<full-sha> |
Secure your secrets
A secret is any credential — API key, database password, deploy token. The rule is simple: secrets never live in the repo or in pipeline config files. Anyone with read access to the repo (or its history) can read a committed secret forever.
Inject secrets at runtime from your CI system’s encrypted secret store, and reference them as environment variables. On a self-hosted Ubuntu runner you can also use a root-owned env file with locked-down permissions.
# Store deploy creds outside the repo, readable only by the runner user
sudo install -m 600 -o ci-runner -g ci-runner /dev/null /etc/ci/secrets.env
sudo tee /etc/ci/secrets.env >/dev/null <<'EOF'
DEPLOY_TOKEN=replace-with-real-token
DB_PASSWORD=replace-with-real-password
EOF
# Load them only at the moment they are needed
set -a
source /etc/ci/secrets.env
set +a
./deploy.sh
Never
echoa secret in a build step — it lands in the build log, which is often world-readable in your dashboard. Mask secrets and scrub logs.
Treat pipeline config as code
Your pipeline definition (the YAML or script that drives the stages) belongs in version control next to your application, reviewed through the same pull-request process. This is “pipeline as code”. It means every change to how you build and deploy is visible, reviewable, and revertable — never a setting someone quietly clicked in a web UI.
# .ci/pipeline.yaml — lives in the repo, reviewed like any other change
stages:
- lint
- test
- build
- deploy
build:
stage: build
image: node:22.14.0-bookworm
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
Because it is code, you also get a single source of truth: a new engineer reads one file and knows exactly how the project ships.
Best Practices
- Keep the full pipeline under ~10 minutes; cache dependencies and run independent stages in parallel.
- Order stages cheapest-first so broken changes fail in seconds, and quarantine flaky tests the moment they appear.
- Build the artifact once, tag it with the git commit SHA, and promote that exact artifact through every environment.
- Pin runtimes, base images, dependencies, and actions to exact versions so builds are reproducible.
- Inject secrets from an encrypted store at runtime; never commit them and never print them to logs.
- Keep pipeline config in version control and change it through reviewed pull requests, not clicks in a UI.