How to Set Up a CI/CD Pipeline with GitHub Actions
Create a secure GitHub Actions pipeline that tests, builds, attests, and deploys immutable container images through OIDC and protected environments.
This builds a complete CI/CD pipeline from scratch using GitHub Actions — testing code on every push, then building and deploying a container image only once tests pass on the main branch.
Step 1: create the workflow file
GitHub Actions workflows live under .github/workflows/ as YAML files:
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
permissions:
contents: read
concurrency:
group: production-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
pull_request:
branches: [main]
Step 2: add the test job
Every pull request and push runs tests first — nothing gets built or deployed if this fails:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run lint
Node.js 20 reached end of life in April 2026, so this example uses the supported Node.js 22 LTS line. A repository with a committed .nvmrc or package.json engines entry can use that as the version source instead. For higher assurance, pin third-party actions to reviewed full commit SHAs and let Dependabot propose controlled updates; a mutable major tag is convenient but is not immutable.
Step 3: add the build job, gated on tests passing and only on main
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
needs: test ensures this job only runs after test succeeds; if: github.ref == 'refs/heads/main' further restricts it to pushes on main specifically, so pull requests only run tests, never build or push an image.
The build action exposes an image digest in steps.build.outputs.digest. Preserve it as a job output and pass the digest—not only the human-readable tag—to deployment. Tags can be moved; a digest identifies the exact manifest that passed the pipeline. Add an SBOM and artifact attestation using GitHub’s documented attestation flow if the repository and package visibility support it.
Step 4: add the deploy job
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: azure/aks-set-context@v4
with:
resource-group: ${{ vars.AKS_RESOURCE_GROUP }}
cluster-name: ${{ vars.AKS_CLUSTER_NAME }}
- run: |
kubectl set image deployment/myapp \
myapp=ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} \
-n production
kubectl rollout status deployment/myapp -n production
The exact cluster-context action varies by Kubernetes provider. This example uses AKS; for another cloud, use its official OIDC federation and cluster-authentication actions. The important property is short-lived, audience-bound credentials issued for the job. Scope the federated identity to the target cluster and grant only the deployment permissions it needs.
environment: production ties this job to a GitHub Environment, letting you configure required reviewers or deployment protection rules directly in the repository settings — a manual approval gate before production deploys, without any extra pipeline logic.
Step 5: store secrets properly
Repository Settings → Secrets and variables → Actions → New repository secret
Store only values that truly must remain secret. Azure client, tenant, and subscription IDs are identifiers and can be Environment variables; cloud federation removes the client secret. Any remaining secret belongs in the protected production Environment, not a repository-wide secret available to unrelated jobs. Never expose production credentials to workflows triggered by untrusted pull-request code.
Step 6: verify the pipeline end to end
Push a small, safe change to a feature branch first, open a pull request, and confirm the test job runs and reports correctly before it’s ever merged to main. Only then merge and confirm build and deploy run in sequence.
git checkout -b test-pipeline
git commit --allow-empty -m "test: verify CI pipeline"
git push origin test-pipeline
Step 7: add status checks as a branch protection rule
Repository Settings → Branches → Add branch protection rule → require status checks to pass (test)
This prevents merging a pull request at all if the test job hasn’t passed — turning “tests should pass before merging” from a social convention into an enforced rule.
Verify deployment and recovery, not only job success
After kubectl rollout status, query the live Deployment and confirm the expected digest. Run a bounded smoke test through the real ingress, and fail the job if it does not meet the release criteria. A green Actions check only proves each command exited successfully; it does not prove customers can use the service.
Define rollback before the first production run. Retain the previous digest, make database changes backward compatible, and test kubectl rollout undo or a GitOps revert in a staging environment. Protect the workflow itself with CODEOWNERS, required review, branch rules, and restricted Environment approvers because a workflow edit can change what production credentials are allowed to do.
Also set explicit job timeouts and use the least capable runner that meets the build’s needs. Self-hosted runners require additional isolation because untrusted build steps can persist on the machine, reach its network, or steal later credentials. Treat their lifecycle and cleanup as production infrastructure.
Pin third-party actions to reviewed commit SHAs for production workflows, then use an automated dependency updater to propose controlled revisions. A movable tag is convenient, but it lets upstream changes alter privileged pipeline code without a corresponding change in your repository.
Why the job dependency chain matters
Structuring this as three distinct jobs — test, build, deploy — each depending on the previous one succeeding, rather than one monolithic script, is what makes the pipeline’s actual behavior legible at a glance in GitHub’s UI, and what ensures a broken test genuinely blocks a bad image from ever being built or deployed, rather than relying on each step manually checking the previous one’s exit code correctly.
Related:
- How to Set Up a Local Kubernetes Cluster with kind
- How to Set Up Centralized Logging for Kubernetes with the EFK Stack
Sources: