Skip to content
Latchkey LogoLatchkey home

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.

Four pipeline stages with the gate on each one, from pull request through to an approved deploy
Each stage is gated on the one before it. The approval gate on deploy is the only one configured outside the workflow file.

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 }}

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

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: npm caches downloads, not node_modules, so npm ci still 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?
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.

Related guides

References

A rebuild should reuse its layers, not download them. Latchkey keeps the layer cache on the runner. Start free → 30-day trial · No credit card