Building with GitHub Actions: CI/CD Workflows for Modern Web Projects

GitHub Actions has become the default CI/CD platform for a reason: tight repository integration, a rich ecosystem of community actions, and no additional infrastructure to manage. A well-crafted workflow automates linting, testing, building, and deploying with minimal configuration overhead.
Structuring Your Workflow Files
Start with the right directory structure. Each workflow file should have a clear purpose:
.github/workflows/
├── ci.yml # Lint, test, build on every push
├── deploy-staging.yml # Deploy to staging on main
├── deploy-production.yml # Manual or tag-triggered deploy
├── security-scan.yml # Weekly dependency scanning
└── cleanup.yml # Daily artifact/cache cleanup
Separate workflows keep logic focused and make changes less risky. A CI fix doesn't affect deployment logic, and vice versa.
Matrix Builds for Cross-Version Testing
Matrix strategies test against multiple versions or configurations in parallel:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage --shard=${{ matrix.shard || 1 }}/${{ matrix.shard-total || 1 }}
- run: npm run build
The cache action with the hash of package-lock.json as part of the key ensures npm caches invalidate only when dependencies change. The restore-keys fallback uses the most recent cache even if dependencies changed partially.
Environment Secrets and Deployment Workflows
GitHub Environments support protected deployments with required reviewers and secret isolation:
name: Deploy Production
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy to S3 and invalidate CloudFront
run: |
aws s3 sync dist/ s3://app-prod-bucket/ --delete
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DIST_ID }} \
--paths "/*"
- name: Notify deployment
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"text": "Production deployed v${{ github.ref_name }}"
}
The production environment requires manual approval by a designated reviewer before the deploy job starts. Separate environment secrets ensure production credentials are never accessible from CI runs triggered by pull requests.
Custom Actions for Reusable Logic
When a workflow step gets complex or needs reuse across repositories, extract it into a custom action:
# .github/actions/deploy-to-k8s/action.yml
name: 'Deploy to Kubernetes'
description: 'Build, push Docker image, and deploy to Kubernetes cluster'
inputs:
image-name:
description: 'Docker image name'
required: true
kubeconfig:
description: 'Kubeconfig contents'
required: true
namespace:
description: 'Kubernetes namespace'
required: false
default: 'default'
runs:
using: 'composite'
steps:
- name: Build and push Docker image
run: |
docker build -t ${{ inputs.image-name }}:${{ github.sha }} .
docker push ${{ inputs.image-name }}:${{ github.sha }}
shell: bash
- name: Deploy to Kubernetes
run: |
echo "${{ inputs.kubeconfig }}" | base64 -d > /tmp/kubeconfig
kubectl --kubeconfig=/tmp/kubeconfig \
-n ${{ inputs.namespace }} \
set image deployment/app \
app=${{ inputs.image-name }}:${{ github.sha }}
shell: bash
Use composite actions (rather than Docker-based actions) for simpler maintenance—they run directly on the runner without building a container.
Caching Strategies Beyond Dependencies
Dependency caching is table stakes. Extend caching to build artifacts and Docker layers:
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: |
.next/cache
public/sw.js
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-
- name: Cache Docker layers
uses: docker/setup-buildx-action@v3
with:
cache-from: type=gha
cache-to: type=gha,mode=max
GitHub Actions cache is limited to 10 GB per repository. Use Docker's inline cache (mode=max) to store layer cache within the registry instead.
Concurrency and Cancel-in-Progress
Prevent wasted runner minutes when pushing multiple commits in quick succession:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This cancels any in-progress workflow run on the same branch when a new push happens. The group ensures refs are isolated—a main branch CI run doesn't cancel a feature branch run.
Build Your CI/CD with SoniNow
GitHub Actions brings robust CI/CD to any GitHub-hosted project with minimal setup overhead. At SoniNow, we help teams design and optimize CI/CD pipelines that catch issues early and deploy with confidence.
Related Insights

Accessibility Testing Automation: axe-core, Lighthouse, and CI Integration
Learn automated accessibility testing with axe-core, Lighthouse CI, and integration into CI/CD pipelines for catching issues before they reach production.

Building AI Chatbots for Customer Support: A Complete Technical Guide
A technical guide to building AI-powered customer support chatbots including LLM integration, RAG architecture, conversation design, escalation workflows, and performance monitoring.

AI Document Processing: Extracting and Structuring Data from Unstructured Documents
Learn how to build AI-powered document processing pipelines for extracting structured data from PDFs, images, and scanned documents using vision models and LLMs.