Building a CI/CD Pipeline with GitHub Actions
Step-by-step guide to building a robust continuous delivery pipeline with GitHub Actions, including testing, security scanning, and automated deployments.
GitHub Actions has become my go-to for CI/CD. It's tightly integrated with your code, the marketplace has actions for everything, and the YAML syntax is straightforward once you know the patterns.
A Complete Pipeline
Here's the pipeline structure I use for most projects:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
Stage 1: Test
Run your test suite on every push and PR:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: matrix.node-version == 22
with:
name: coverage-report
path: coverage/
The matrix strategy runs tests across multiple Node versions in parallel. This catches compatibility issues early.
Stage 2: Security Scanning
Security should be automated, not an afterthought:
security:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
- name: Audit dependencies
run: npm audit --audit-level=high
Trivy scans your filesystem for known vulnerabilities. Pair it with npm audit for comprehensive coverage.
Stage 3: Build and Push
Build your Docker image and push to a registry:
build:
runs-on: ubuntu-latest
needs: [test, security]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
The cache-from and cache-to lines enable GitHub Actions' built-in layer caching — this significantly speeds up subsequent builds.
Stage 4: Deploy
Deploy to your Kubernetes cluster:
deploy:
runs-on: ubuntu-latest
needs: build
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v3
- uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG }}
- name: Update image tag
run: |
kubectl set image deployment/my-app \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
--namespace=production
- name: Verify rollout
run: |
kubectl rollout status deployment/my-app \
--namespace=production \
--timeout=300s
The environment: production line enables GitHub's environment protection rules — you can require manual approvals before production deploys.
Reusable Workflows
Once your pipeline matures, extract common patterns into reusable workflows:
# .github/workflows/reusable-deploy.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
image-tag:
required: true
type: string
secrets:
KUBE_CONFIG:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: azure/setup-kubectl@v3
- uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG }}
- run: |
kubectl set image deployment/my-app \
app=${{ inputs.image-tag }} \
--namespace=${{ inputs.environment }}
Then call it from any workflow:
deploy-staging:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
image-tag: ghcr.io/my-org/my-app:abc123
secrets:
KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}
Tips I've Learned
Cache aggressively. Use actions/cache or built-in caching for package managers. Build times drop dramatically.
Use concurrency to prevent duplicate runs:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This cancels in-flight runs when you push new commits to the same branch.
Keep secrets minimal. Use OIDC federation instead of long-lived credentials where possible. AWS, GCP, and Azure all support this natively.
Monitor your pipeline. Track build times, failure rates, and flaky tests. A slow or unreliable pipeline erodes developer trust.
Wrapping Up
A well-built CI/CD pipeline is one of the highest-leverage investments you can make. It catches bugs before they reach production, enforces security policies automatically, and gives your team confidence to ship fast.
Start simple — test, build, deploy — and layer on security scanning, caching, and reusable workflows as your needs grow.