A CI/CD pipeline example you can actually run
This CI/CD pipeline example is a whole workflow file rather than a fragment: it tests every change, builds a container image only from main, and deploys behind an approval gate. The sections after it name the four lines that are doing the real work, and the one file you still have to write yourself.

Most published pipeline examples are excerpts. They show the three steps that illustrate the point and assume the rest, which is why copying one into an empty repository so rarely produces a pipeline that runs. The file below is complete. It installs dependencies, lints, tests, builds and pushes an image, and deploys behind a manual approval, and every key in it is one the workflow schema allows on the job shape it sits on.
It is written for GitHub Actions because that is where most teams start, but the shape is portable. Four stages, each gated on the one before it, with the deploy held behind an approval that lives outside the file, is the same design on GitLab CI, Bitbucket Pipelines and Jenkins. Only the syntax moves, so the reasoning in each section below survives the translation even though the YAML does not.
The complete pipeline
Copy this into .github/workflows/ci-cd.yml. On a repository with a package.json and a Dockerfile it runs as written, with one exception named under the code: there is no honest generic deploy script, so that is the file you supply.
The action versions are the current majors as of 21 September 2026. Pinning to a major rather than a full version is the usual trade: you get patched automatically and you accept that a major tag moves. If you would rather it never moved, pin the commit SHA instead and accept that you now own the upgrades.
name: CI/CD
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh ghcr.io/${{ github.repository }}:${{ github.sha }}What each stage is doing, and why
The three jobs are ordered by what each one costs. Testing is the cheapest thing in the file, so it runs on every pull request and on every push. Building an image costs more and produces an artifact nobody wants from a branch that does not merge, so it is gated on needs: test and on the ref being main. Deploying costs the most, because it is the only stage a mistake reaches users through, so it is gated on the build and then on a person.
Reading it in that order also explains the tagging choice. The image is tagged with github.sha and never with latest, because a SHA tag names exactly one build of exactly one commit. That is what makes a deploy traceable after the fact and a rollback possible at all: the previous image still exists and is still addressable by name.
Turning the deploy gate on
The environment: production line does nothing on its own. It names an environment, and until that environment exists and carries a protection rule, the deploy job runs straight through. Create it under Settings, then Environments, then add required reviewers. GitHub allows up to six people or teams, and only one of them needs to approve for the job to proceed.
One thing worth being precise about, because it is easy to assume the stronger version: putting the approval on the environment does not by itself stop someone approving their own deploy. Preventing that is a separate checkbox on the same screen, the one that stops users approving workflow runs they triggered. If the reason you added reviewers was to get a second pair of eyes rather than a second click, turn that on too, or the gate is satisfied by the person who opened it.
What the cache lines actually cache
Two different caches are switched on in this file and they are often confused. cache: npm on setup-node caches the package manager's download cache, keyed on the hash of your lockfile. It explicitly does not cache node_modules, so npm ci still runs and still does a clean install on every job. What changes is that the install stops going to the network for tarballs it has already fetched, which is where the minute or so comes from.
The cache-from and cache-to lines on the Docker build are a different mechanism entirely: they persist BuildKit layers between runs using the Actions cache as the backend. mode=max exports the intermediate layers as well as the final ones, which is what makes a cache hit reach further back into a multi-stage Dockerfile. Without those two lines every image build starts from nothing, and no amount of dependency caching in the test job affects that.
What this example leaves out on purpose
Every omission below is a decision rather than an oversight, and each one is a thing published examples tend to include badly. Adding any of them is a second commit, which is the point: a pipeline assembled all at once tends to fail all at once, for reasons that are hard to tell apart.
- A deploy script. Deployment is where the pipeline meets your infrastructure, and a fabricated command here would be the one line in the file nobody could run.
- Secrets beyond
GITHUB_TOKEN. That one is created automatically by GitHub at the start of each workflow job and expires when the job finishes, so it needs no setup. Anything else is a decision about your cloud rather than about CI. - A matrix. Testing several Node versions at once is a few lines and multiplies your billed minutes, so it belongs after the single-version pipeline is green.
- Rollback. Tagging by SHA is what makes rollback possible, because the previous image is still there and still addressable. Redeploying it is deployment tooling, not pipeline syntax.
Making it fast enough that people keep it
A pipeline gets deleted when waiting for it costs more than trusting it, so the speed work is not an optimization pass you do later. The two cache lines above are most of it. Splitting test from build is the rest: a failing test that never triggers a build saves the whole build stage, every time, and that saving compounds across a busy repository far faster than any single tuning change.
The part teams miss is that half the number they care about is not run time at all. A three minute pipeline that waits four minutes for a runner is a seven minute pipeline to everyone waiting on it, and caching cannot touch that half. Read queue time and run time as two separate numbers from the start, because the fixes for them have nothing in common.
- Keep
cache: npmonsetup-nodeand keep the lockfile committed, or the key changes on every run and the cache never hits. - Keep
cache-fromandcache-toon the Docker build, and usemode=maxso intermediate layers are exported too. - Keep test and build as separate jobs, so a failing test never pays for a build.
- Keep the
concurrencyblock, so a fast follow-up push cancels the run it supersedes instead of queueing behind it. - Measure queue time separately from run time, because no caching change moves the queue half.
Key takeaways
- The file is complete on purpose: every key in it is allowed on the job shape it sits on.
- Each stage is gated on the one before it, ordered by what the stage costs to run.
- The approval gate lives on the GitHub Environment, not in the workflow file, so a workflow edit cannot remove it.
- Preventing self-approval is a separate setting from adding required reviewers.
cache: npmcaches downloads, notnode_modules, sonpm cistill runs every time.- Tag images with the commit SHA, because that is what makes a rollback addressable later.
Frequently asked questions
What is a CI/CD pipeline, in practice?
How do I implement a CI/CD pipeline from scratch?
Should the deploy stage run automatically?
Why tag images with the commit SHA instead of latest?
latest names whatever was pushed most recently, so it cannot tell you what is running in production and it cannot take you back. A SHA tag names exactly one build of exactly one commit. That is what makes a deploy traceable and a rollback possible, because the previous image is still stored and still addressable by name.Does this pipeline example work outside GitHub Actions?
Related guides
References
- GitHub Actions workflow syntax: concurrency, needs and permissions (read 2026-09-21)
- Managing environments for deployment: required reviewers (read 2026-09-21)
- Automatic token authentication: GITHUB_TOKEN lifetime and scope (read 2026-09-21)
- actions/setup-node: the cache input does not cache node_modules (read 2026-09-21)
- GitHub Actions documentation