Skip to main content

Set up a CI/CD pipeline with GitHub Actions

A pipeline that builds a container image and updates a cluster, with no long-lived cloud credentials stored in GitHub. We build it in four layers, each useful on its own.

Time: about 35 minutes. Prerequisites: a GitHub repository containing the Dockerfile from the previous guide.

Layer 1 — test on every pull request

.github/workflows/test.yml
name: test

on:
pull_request:
push:
branches: [main]

# Cancel superseded runs on the same branch to save runner minutes.
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
unit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci
- run: npm run lint
- run: npm test -- --coverage

Two details that matter more than they look:

  • concurrency cancels an in-progress run when you push again to the same branch. On a busy repository this is the single biggest saving available.
  • permissions: contents: read shrinks the default GITHUB_TOKEN scope. Every job should declare the minimum it needs rather than inheriting write access to the whole repository.

Layer 2 — build and publish a multi-arch image

.github/workflows/build.yml
name: build

on:
push:
branches: [main]
tags: ['v*']

concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
packages: write # push to ghcr.io
id-token: write # required for keyless cosign signing
attestations: write

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
image:
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Derive tags and labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long

- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true

cache-from/cache-to: type=gha stores BuildKit's layer cache in GitHub's own cache service, so the dependency layers survive between runs on ephemeral runners. mode=max caches intermediate stages too, which is what makes multi-stage builds fast.

Layer 3 — scan and sign

.github/workflows/build.yml (continued)
verify:
needs: image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
security-events: write
steps:
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ${{ env.REGISTRY }}/${{ github.repository }}@${{ needs.image.outputs.digest }}
format: sarif
output: trivy.sarif
severity: HIGH,CRITICAL
exit-code: '1'
ignore-unfixed: true

- name: Upload results to code scanning
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy.sarif

- name: Install cosign
uses: sigstore/cosign-installer@v3

- name: Sign the image (keyless)
run: |
cosign sign --yes \
"${REGISTRY}/${IMAGE}@${DIGEST}"
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ github.repository }}
DIGEST: ${{ needs.image.outputs.digest }}

Keyless signing is the part worth understanding. There is no private key to store or rotate: cosign exchanges the workflow's OIDC token for a short-lived certificate from Fulcio, signs, and records the signature in the Rekor transparency log. Verification then asserts which workflow in which repository produced the image:

cosign verify ghcr.io/OWNER/demo@sha256:... \
--certificate-identity-regexp='^https://github.com/OWNER/demo/.github/workflows/build.yml@refs/heads/main$' \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com

Layer 4 — deploy

There are two honest options, and they behave very differently.

Direct deploy from CI
deploy:
needs: [image, verify]
runs-on: ubuntu-latest
environment: production # enables required reviewers
permissions:
contents: read
id-token: write # for cloud OIDC, not a static kubeconfig
steps:
- uses: actions/checkout@v4

- name: Authenticate to the cloud via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: eu-west-1

- name: Fetch kubeconfig
run: aws eks update-kubeconfig --name prod --region eu-west-1

- name: Roll out the new digest
run: |
kubectl set image deployment/web \
web=ghcr.io/${{ github.repository }}@${{ needs.image.outputs.digest }} \
-n prod
kubectl rollout status deployment/web -n prod --timeout=5m

Choosing between push and pull

Push (kubectl from CI)Pull (Argo CD / Flux)
Cluster credentials in CIRequired (use OIDC)None
Drift detectionNoneContinuous
RollbackRe-run an older workflowgit revert
Works for private clustersNeeds network reachYes — the agent dials out
Setup costLowHigher

Push-based is fine for a single environment and a small team. Once you have more than a couple of clusters, the pull model's audit trail and drift correction pay for themselves quickly.

Hardening checklist

  • permissions: declared explicitly on every job — never rely on the default token scope.
  • Third-party actions pinned to a full commit SHA, not a mutable tag.
  • concurrency groups set so superseded runs are cancelled.
  • timeout-minutes on every job, so a hung step cannot burn an hour.
  • Deployments guarded by a GitHub environment with required reviewers.
  • pull_request_target avoided unless you fully understand it — it runs with write permissions and access to secrets, in the context of untrusted fork code.
  • Images referenced by digest end to end.
# Pin by SHA — a tag can be moved to point at different code
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Next steps