Why CI/CD Is Non-Negotiable
Manual deployments are the enemy of fast, reliable software delivery. Every time a developer SSHs into a server and runs commands by hand, they introduce risk: missed steps, wrong environment variables, untested code reaching production. Continuous Integration and Continuous Deployment (CI/CD) eliminates this entirely by automating the path from code commit to live deployment.
GitHub Actions is now the most popular CI/CD platform for open-source and commercial projects alike. It’s built into GitHub, requires zero external infrastructure, and offers a generous free tier. In this guide, we’ll build a production-grade pipeline for a Node.js application from scratch.
Understanding GitHub Actions Concepts
Before writing YAML, let’s clarify the terminology. A workflow is a configurable automated process defined in a YAML file inside .github/workflows/. A workflow contains one or more jobs, and each job contains a sequence of steps. Jobs run in parallel by default but can depend on each other. Steps can run shell commands or use pre-built actions from the GitHub Marketplace.
Workflows are triggered by events: push, pull_request, schedule, manual dispatch, and many others. You can filter triggers by branch, path, or tag.
The Complete Workflow File
Create .github/workflows/ci-cd.yml in your repository. This workflow handles linting, testing, building, and deploying:
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint-and-test:
name: Lint & Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npx eslint . --max-warnings 0
- name: Run unit tests
run: npm test -- --coverage
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
build:
name: Build Docker Image
needs: lint-and-test
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
name: Deploy to Staging
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy to staging server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:develop
docker compose -f docker-compose.staging.yml up -d
docker system prune -f
deploy-production:
name: Deploy to Production
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
docker compose -f docker-compose.prod.yml up -d --no-deps app
docker system prune -f
- name: Verify deployment
run: |
sleep 30
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.com/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
Managing Secrets Securely
Never hardcode credentials in workflow files. GitHub provides encrypted secrets at the repository and environment level. Navigate to Settings > Secrets and variables > Actions in your repository to add them.
# Required secrets for this pipeline:
# STAGING_HOST - IP or hostname of staging server
# PROD_HOST - IP or hostname of production server
# DEPLOY_USER - SSH username for deployment
# SSH_PRIVATE_KEY - Private key for SSH authentication
# GITHUB_TOKEN - Automatically provided by GitHub
For environment-specific secrets, use GitHub Environments. You can add required reviewers so production deployments need manual approval before proceeding.
Optimizing Pipeline Speed with Caching
Slow pipelines kill developer productivity. The actions/setup-node@v4 action with cache: 'npm' automatically caches the npm global cache. For Docker builds, we use GitHub Actions cache (type=gha) to cache image layers. Here are additional caching strategies:
# Cache Cypress binary for E2E tests
- name: Cache Cypress
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
# Cache Playwright browsers
- name: Cache Playwright
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
Adding Notifications and Status Badges
Keep your team informed about pipeline status. Add a Slack notification step at the end of your deploy job:
- name: Notify Slack
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,workflow
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Add a status badge to your README:

Branch Protection Rules
CI/CD pipelines only work if you enforce them. Go to Settings > Branches > Branch protection rules for your main branch and enable: Require status checks to pass before merging, selecting the lint-and-test job. Also enable Require pull request reviews before merging. This ensures no untested code reaches your main branch.
Conclusion
You now have a production-grade CI/CD pipeline that lints, tests, builds Docker images, and deploys to staging and production with zero manual intervention. The key principles are: automate everything repeatable, gate deployments behind tests, keep secrets out of code, and cache aggressively. From here, consider adding E2E tests with Playwright, database migration steps, and canary deployments. The workflow file is your infrastructure — treat it with the same care as application code.