However well you build an image and lock down how it runs (the build, the run), something still has to build it on every change and push it where the server can pull from. For a long time that something was me, on my laptop, hoping I had not skipped a check.
A CI pipeline does it on every push instead: run the checks, build the image, scan it, and publish it to a registry. A registry is the place built images live so a server can pull them; here it is GHCR, GitHub’s own. The pipeline is a GitHub Actions workflow, a YAML file in .github/workflows/ that GitHub runs for you on each push. Here is its shape.
name: CI
on: pull_request: push: branches: [main] tags: ['v*'] schedule: - cron: '0 4 1 * *' # monthly, explained at the end workflow_dispatch:
permissions: {} # grant nothing by default
jobs: quality: # does the code pass? image: # build, scan, publishTwo jobs run in parallel. One asks whether the code is good; the other turns it into a published image. Neither trusts the input it is handed, and the second never pushes anything from a pull request.
The gate
The quality job is the gate. A red check here stops a merge instead of printing a warning nobody reads.
quality: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@<sha> # v4 with: persist-credentials: false - uses: actions/setup-node@<sha> # v4 with: node-version: 22 cache: npm - run: npm ci --ignore-scripts - run: npx svelte-kit sync - run: npx prettier --check . - run: npx eslint . - run: npm audit --omit=dev --audit-level=high - run: npm run checknpm ci --ignore-scripts installs dependencies without running their install hooks, the same supply-chain guard the image build uses. Then formatting, linting, and a type check, each one able to fail the job. npm audit --omit=dev --audit-level=high is the one people skip: it checks the production dependencies for known vulnerabilities and fails on anything rated high or worse, so a dependency with a public exploit blocks the merge.
persist-credentials: false tells the checkout not to leave a Git token sitting in the workspace after it clones, where a later step or a compromised dependency could read it.
Build once, reuse the layers
The image job builds with Buildx, Docker’s extended builder, and leans on a layer cache so it does not rebuild the world every time.
- uses: docker/build-push-action@<sha> # v6 with: context: . load: true # keep the image local for the smoke test and scan tags: ghcr.io/you/app:ci cache-from: type=gha cache-to: type=gha,mode=max no-cache: ${{ github.event_name == 'schedule' }}cache-from and cache-to with type=gha store the build layers in GitHub’s own cache between runs. A push that only touches application code reuses the dependency and base layers untouched and finishes in a fraction of the cold time. load: true keeps the built image on the runner so the next two steps can run it and scan it before anyone publishes it. Ignore no-cache for now; it earns its own section.
Smoke test what you ship
A build that succeeds is not a container that runs. Before publishing, the job starts the image and checks it answers, with the same flags production uses.
- name: Smoke test run: | docker run -d --name app \ --read-only --tmpfs /tmp:size=64m,mode=1777 \ --cap-drop ALL --security-opt no-new-privileges \ -e API_KEY=ci-dummy ghcr.io/you/app:ci # wait for the health check, then curl /healthz and the home pageThe flags are not decoration. They are the read-only filesystem and dropped privileges from the compose file. A container that passes a bare docker run can still fail under those constraints, which is its own story. Testing it any other way grades a container nobody ships.
Scan before you publish
- uses: aquasecurity/trivy-action@<sha> # v0.x with: image-ref: ghcr.io/you/app:ci severity: CRITICAL,HIGH ignore-unfixed: true exit-code: '1'Trivy reads the image and lists known vulnerabilities in its OS packages and dependencies. exit-code: '1' turns a finding into a failed job, so an image with a critical hole never reaches the registry. ignore-unfixed: true skips the ones with no available patch, the ones you could not fix today even if you wanted to, so the gate fails only on things you can act on.
Publish
- id: meta uses: docker/metadata-action@<sha> # v5 with: images: ghcr.io/you/app tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha
- uses: docker/build-push-action@<sha> # v6 if: github.event_name != 'pull_request' with: context: . push: true tags: ${{ steps.meta.outputs.tags }} provenance: mode=max sbom: trueThe metadata step computes the tags: latest on the main branch, plus a sha- tag for the exact commit, so a deploy can pin one immutable build and roll back by changing a string. The push runs only when the event is not a pull request, which is what keeps a fork’s pull request from publishing anything. provenance and sbom attach two signed records to the image: where it was built, and a list of everything inside it. Both let whoever pulls the image check what they are running.
The monthly rebuild, and the trap inside it
A digest-pinned base image stops getting security patches the day you pin it. Two things keep it fresh. Dependabot watches the base tag and opens a pull request when a new digest appears, which the pipeline above validates like any other change. And once a month, the schedule trigger rebuilds and republishes even when nothing changed.
That scheduled rebuild has a trap, and it is the reason for the no-cache line earlier:
no-cache: ${{ github.event_name == 'schedule' }}The image runs apk upgrade to pull the latest OS patches at build time. But the layer cache keys on the Dockerfile instruction, and RUN apk upgrade never changes. So the cache treats that layer as a hit and replays the patches from the last build, the old ones. A cached monthly rebuild produces a byte-identical image and a green check, while doing nothing for security. no-cache on the scheduled run forces every layer to rebuild, so apk upgrade executes instead of being replayed, and the new image carries the patches released since last month. Without that one line, the whole monthly job is a confident no-op.
Harden the pipeline itself
The workflow is software that runs with access to your registry, so it is worth the same care as the rest.
permissions: {} at the top grants each job nothing, and every job adds back only what it needs: contents: read to check out, packages: write to push. A leaked token then carries the least it can.
Every action is pinned to a commit hash, not a tag:
- uses: actions/checkout@<40-char-sha> # v4A tag like @v4 moves, and an action you trust can be replaced under that tag by an attacker who takes over the repository. A commit hash is the same exact-bytes pin the base image uses, for the same reason. Dependabot bumps these too, so they do not rot.
The last rule is to never drop untrusted text, a pull request title, a branch name, into a run: line, where it would run as shell. Pass it through an environment variable instead. A pipeline that builds and publishes is a tempting place to smuggle code into.
What this costs
CI build minutes are not free, and the monthly no-cache rebuild pays full price every time by design. A cold build is slower than your laptop with a warm cache, though type=gha narrows the gap on normal pushes.
ignore-unfixed is a real trade: it means you ship images with known, unpatched vulnerabilities, because the alternative is a gate that can never go green until upstream ships a fix. You accept what you cannot act on and stay loud about the rest. And SHA-pinning every action turns into a steady trickle of Dependabot pull requests, which is the cost of not trusting a moving tag.
Your pipeline is not this pipeline
The registry is GHCR and the syntax is GitHub Actions. GitLab CI, Forgejo Actions and the rest reach the same four moves: gate, build, scan, publish, then refresh on a schedule. A project that is not a container still gates and publishes an artifact; the shape holds, the YAML does not. Read the steps as a checklist, not a file to fork.
What you end up with
Every push is linted, type-checked, audited, built, smoke-tested under production flags, scanned, and only then published, with provenance and an SBOM attached. The base image refreshes itself every month for real, not in name. Getting that freshly published image onto the server without me typing docker pull is a problem of its own.
Further reading
The supply-chain ideas behind the provenance and SBOM, in full:
- Security hardening for GitHub Actions is GitHub’s own version of the hardening section here: SHA-pinning, least-privilege permissions, and keeping untrusted input out of
run:. - SLSA is the supply-chain framework that the attached provenance and SBOM are the first steps toward.
- Sigstore’s cosign signs the published image so the server can verify it pulled what the pipeline built, the step past provenance.