# A CI/CD pipeline example you can actually run

> A complete CI/CD pipeline example for GitHub Actions, explained stage by stage, including the gates and cache lines most published examples leave out.

Source: https://latchkey.dev/learn/ci-explained/ci-cd-pipeline-example  
Updated: 2026-09-21

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.

```.github/workflows/ci-cd.yml
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 }}
```

> The one thing you must supply is `deploy.sh`. Deployment is the stage that depends entirely on where you run, and a generic `kubectl apply` line here would be an example of nothing.

## 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.

| Stage | When it runs | What gates it | Why the gate is there |
| --- | --- | --- | --- |
| test | Every push and every pull request | Nothing | It is the cheapest job in the file, so a broken commit costs one short job rather than a build and a registry push. |
| build | Pushes to main only | `needs: test` and an `if` on the ref | An image built from a branch that never merges is a push, a pull and a stored layer set that nobody asked for. |
| deploy | After a successful build | `needs: build` and the environment approval | The approval requirement lives on the GitHub Environment, so the workflow file does not decide who may ship. |

> The `concurrency` block at the top is the least obvious part of the file. The docs put it plainly: specifying `cancel-in-progress: true` also cancels any currently running job or workflow in the same concurrency group, so pushing twice in quick succession cancels the first run instead of racing it.

## 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.

> Required reviewers are configured on the environment rather than in the workflow file, which means editing `ci-cd.yml` cannot remove the gate. It also means the gate is invisible in the diff, so it is worth saying in your README that it exists.

## 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.

> Both caches key on a lockfile, so a repository without a committed lockfile gets a cache that misses on every run. The setup-node README is blunt about this: committing your package manager's lockfile is strongly recommended, for performance and for security.

## 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: npm` on `setup-node` and keep the lockfile committed, or the key changes on every run and the cache never hits.
- Keep `cache-from` and `cache-to` on the Docker build, and use `mode=max` so intermediate layers are exported too.
- Keep test and build as separate jobs, so a failing test never pays for a build.
- Keep the `concurrency` block, 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.

## FAQ

### What is a CI/CD pipeline, in practice?

It is a file in your repository listing what should happen when code changes: usually install, test, build, then deploy. The provider reads that file on every push, runs the stages in order on a fresh machine, and reports the result back to the pull request. Nothing about it is magic, and everything about it is version controlled alongside the code it tests.

### How do I implement a CI/CD pipeline from scratch?

Start with one job that installs dependencies and runs tests on every pull request, and merge that before adding anything else. Then add a build stage gated on tests passing, then a deploy stage gated on the build. Each step is independently useful, which matters because a pipeline built in one commit tends to fail in one commit, and separating the causes afterwards is slow.

### Should the deploy stage run automatically?

Automatic deploy on merge to main is reasonable once you trust the tests, and a manual approval gate is the reasonable default before that. Because the requirement lives on the GitHub Environment rather than in the workflow file, you can switch between the two without touching the pipeline, so it is not a decision you have to make up front.

### 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?

The structure does, and the YAML does not. Four stages, each gated on the previous, with deployment held behind an approval configured outside the pipeline file, is the same design on GitLab CI, Bitbucket Pipelines, CircleCI and Jenkins. The keys and the action names change completely, so treat the sections above as the thing you are porting.

## References

- [GitHub Actions workflow syntax: concurrency, needs and permissions (read 2026-09-21)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [Managing environments for deployment: required reviewers (read 2026-09-21)](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments)
- [Automatic token authentication: GITHUB_TOKEN lifetime and scope (read 2026-09-21)](https://docs.github.com/en/actions/concepts/security/github_token)
- [actions/setup-node: the cache input does not cache node_modules (read 2026-09-21)](https://github.com/actions/setup-node#caching-global-packages-data)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
