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.