Skip to content
daniel@cosenza:~/blog
SRE & DevOpsHow-To September 8, 2025 3 min read

How to Set Up a CI/CD Pipeline with GitHub Actions

A complete, working GitHub Actions workflow that tests, builds, and deploys a containerized application on every push to main.

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

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@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run lint

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
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        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.

Step 4: add the deploy job

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: azure/k8s-set-context@v4
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.KUBE_CONFIG }}
      - run: |
          kubectl set image deployment/myapp \
            myapp=ghcr.io/${{ github.repository }}:${{ github.sha }} \
            -n production
          kubectl rollout status deployment/myapp -n production

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

Add KUBE_CONFIG and any other credentials here — never commit credentials directly into the workflow file or repository.

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.

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.