Skip to content
SRE & DevOpsHow-To Published Updated 5 min readViews unavailable

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:

Sources:

Comments