A GitHub Actions pipeline, end to end
name: build-and-deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: "1"
- name: Deploy
run: |
kubectl set image deployment/docktionary-app \
app=ghcr.io/${{ github.repository }}:${{ github.sha }}
kubectl rollout status deployment/docktionary-appTag by commit SHA, not "latest"
Tagging every build with the git SHA (or a semantic version plus the SHA) gives every deployed image a unique, traceable tag, and makes kubectl rollout undo meaningful — you can see exactly which commit is running and roll back to the precise previous one, something :latest can't give you.
GitOps: let the cluster pull, don't push to it
Rather than a CI job running kubectl apply directly against production, a GitOps controller (Argo CD, Flux) running inside the cluster watches a git repository of manifests and applies changes itself. CI's job becomes: build the image, then update the image tag in a manifest repo via a pull request. This gives you a full audit trail in git, and a controller already in place to detect and correct drift if someone changes the cluster out-of-band.
# CI's last step becomes a commit, not a deploy
yq -i '.spec.template.spec.containers[0].image = "ghcr.io/org/app:'"$GITHUB_SHA"'"' \
manifests/deployment.yaml
git commit -am "deploy app@$GITHUB_SHA"
git push
# Argo CD (already watching the repo) picks up the change and applies itScanning images before they ship
Scanning for known CVEs (Trivy, Grype) as a required CI step, with a severity threshold that fails the build, is the cheapest point in the pipeline to catch a vulnerable base image or dependency — far cheaper than catching it once it's already serving traffic.