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
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:
concurrencycancels 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: readshrinks the defaultGITHUB_TOKENscope. 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
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
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.
- Push (kubectl)
- Pull (GitOps)
- Cloud OIDC trust
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
bump:
needs: [image, verify]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: OWNER/infra
token: ${{ secrets.INFRA_REPO_TOKEN }}
- name: Pin the new digest in the manifests
run: |
yq -i '.image.digest = "${{ needs.image.outputs.digest }}"' \
environments/prod/values.yaml
- name: Commit
run: |
git config user.name "ci-bot"
git config user.email "ci-bot@users.noreply.github.com"
git commit -am "chore: bump web to ${{ needs.image.outputs.digest }}"
git push
# Argo CD or Flux, running inside the cluster, notices the commit and
# reconciles. CI never holds cluster credentials at all.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:OWNER/demo:ref:refs/heads/main"
}
}
}]
}
Choosing between push and pull
| Push (kubectl from CI) | Pull (Argo CD / Flux) | |
|---|---|---|
| Cluster credentials in CI | Required (use OIDC) | None |
| Drift detection | None | Continuous |
| Rollback | Re-run an older workflow | git revert |
| Works for private clusters | Needs network reach | Yes — the agent dials out |
| Setup cost | Low | Higher |
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.
-
concurrencygroups set so superseded runs are cancelled. -
timeout-minuteson every job, so a hung step cannot burn an hour. - Deployments guarded by a GitHub environment with required reviewers.
-
pull_request_targetavoided 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
- Build and publish a Docker image — the Dockerfile this pipeline builds.
- Kubernetes security basics — where signing and scanning fit in the wider picture.
- Debugging pod failures — for when the rollout does not go green.