1. What is CI/CD and Why It Matters
Continuous Integration (CI): Every code push triggers an automated pipeline that builds the code, runs linting, and runs tests. The goal: detect integration errors immediately, before they compound. Teams that practice CI report fewer bugs in production and dramatically reduced integration hell (the nightmare of merging long-lived branches).
Continuous Deployment (CD): Every change that passes CI is automatically deployed to production (or staging). Eliminates manual deployment steps, reduces the batch size of changes (smaller deploys = less risk), and shortens the feedback loop from code to user.
Business impact: Google's DevOps Research and Assessment (DORA) report consistently shows that elite engineering teams deploy 973× more frequently with 6,570× faster change lead times than low-performing teams. CI/CD is the primary enabler of this performance gap.
2. GitHub Actions Anatomy
A GitHub Actions workflow is a YAML file stored in .github/workflows/ in your repository:
name: CI # Workflow name (shown in UI)
on: # Triggers (see section 3)
push:
branches: [main]
pull_request:
branches: [main]
env: # Global environment variables
NODE_VERSION: '20'
jobs: # One or more parallel jobs
test: # Job ID
name: Test # Job display name
runs-on: ubuntu-24.04 # Runner OS
steps: # Sequential steps within a job
- name: Checkout code
uses: actions/checkout@v4 # Use an Action from marketplace
- name: Set up Node.js
uses: actions/setup-node@v4
with: # Action inputs
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci # Shell command
- name: Run tests
run: npm test
env: # Step-level environment variables
CI: true
3. Triggers: When Workflows Run
| Trigger | YAML | Use Case |
|---|---|---|
| Push to branch | on: push: branches: [main] | Run CI on every merge to main |
| Pull Request | on: pull_request: branches: [main] | Run tests before PR can be merged |
| Schedule (cron) | on: schedule: - cron: '0 8 * * 1' | Weekly scheduled tasks, nightly builds |
| Manual trigger | on: workflow_dispatch: | Deploy on-demand with optional inputs |
| Tag push | on: push: tags: ['v*'] | Release pipeline triggered by version tags |
| Another workflow | on: workflow_call: | Reusable workflow called by others |
| Issue/PR comment | on: issue_comment: types: [created] | Slash command bots (e.g., /deploy staging) |
4. Runners: Where Workflows Run
Runners are the virtual machines that execute workflow jobs:
- GitHub-hosted runners:
ubuntu-24.04,ubuntu-22.04,windows-latest,macos-latest. Free tier: 2000 minutes/month for public repos; 500 minutes/month for private (then metered). Each job gets a fresh VM — clean, isolated environment. - Self-hosted runners: Your own servers (on-premise or cloud). No minute costs; can access internal networks; can use specialised hardware (GPUs, high-memory). Set up with:
Settings → Actions → Runners → New self-hosted runner. - Larger runners: GitHub offers 4-core, 8-core, 16-core, and GPU runners for premium plan users. Useful for intensive test suites or model training pipelines.
5. Actions Marketplace
Actions are reusable workflow building blocks. Browse 20,000+ at github.com/marketplace. Essential actions:
| Action | Purpose |
|---|---|
actions/checkout@v4 | Clone your repository into the runner |
actions/setup-node@v4 | Install Node.js (with npm/yarn cache) |
actions/setup-python@v5 | Install Python (with pip cache) |
actions/cache@v4 | Cache arbitrary directories between runs |
actions/upload-artifact@v4 | Save files (build output, test reports) to share between jobs |
docker/build-push-action@v5 | Build and push Docker images (with layer caching) |
aws-actions/configure-aws-credentials@v4 | Authenticate to AWS (supports OIDC keyless auth) |
google-github-actions/deploy-cloudrun@v2 | Deploy to Google Cloud Run |
azure/webapps-deploy@v3 | Deploy to Azure App Service |
6. Matrix Builds for Multi-Version Testing
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # don't cancel other matrix jobs on failure
matrix:
os: [ubuntu-24.04, windows-latest]
node: ['18', '20', '22']
include:
# Add extra variable for a specific combination
- os: ubuntu-24.04
node: '20'
coverage: true
exclude:
- os: windows-latest
node: '18'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
- name: Upload coverage
if: matrix.coverage == true
run: npm run coverage
7. Caching Dependencies
Without caching, npm ci or pip install downloads and installs all dependencies on every run. With actions/setup-node@v4's built-in cache: 'npm', the ~/.npm cache is stored between runs. For custom caching:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Docker layer caching (dramatically speeds up image builds)
- uses: docker/build-push-action@v5
with:
context: .
cache-from: type=gha # GitHub Actions Cache as Docker cache
cache-to: type=gha,mode=max # Save all layers
push: true
tags: myimage:latest
8. Build and Push Docker Images
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# Set up Docker Buildx (required for multi-platform builds)
- uses: docker/setup-buildx-action@v3
# Login to Docker Hub
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} # use access token, not password
# Extract metadata for tags (automatically tags :latest, :1.2.3, :sha-abc123)
- uses: docker/metadata-action@v5
id: meta
with:
images: myuser/myapp
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
# Build and push (multi-platform: amd64 + arm64)
- uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
9. Deploying to Cloud Platforms
Deploy to Vercel
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
Deploy to Fly.io
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Deploy to AWS ECS
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- run: |
aws ecs update-service \
--cluster prod \
--service my-app \
--force-new-deployment
10. Secrets Management
- Repository secrets: Settings → Secrets and variables → Actions. Available to all workflows.
- Environment secrets: Scoped to a specific environment (staging, production). Can require manual approval via environment protection rules — nobody deploys to production without a reviewer.
- Organisation secrets: Available across all repos in an organisation. Useful for shared credentials like a Docker Hub token.
- OIDC (no long-lived secrets): Use
aws-actions/configure-aws-credentials@v4with an IAM role — GitHub issues a short-lived token. No AWS keys stored anywhere. Best practice for cloud deployments.
- run: echo "Deploy to ${{ secrets.DEPLOY_HOST }}"
# Output: Deploy to **** (secrets are masked in logs automatically)
11. Complete Production Workflow Example
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
id-token: write # for OIDC
jobs:
# --- Job 1: Lint and test ---
test:
name: Lint & Test (Node ${{ matrix.node }})
runs-on: ubuntu-24.04
strategy:
matrix:
node: ['18', '20']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: matrix.node == '20'
with:
name: coverage
path: coverage/
# --- Job 2: Build Docker image (only on main branch) ---
build:
name: Build & Push Docker Image
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # built-in, no setup needed
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: type=sha,prefix=
- 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
# --- Job 3: Deploy to staging ---
deploy-staging:
name: Deploy to Staging
needs: build
runs-on: ubuntu-24.04
environment: staging
steps:
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --image ghcr.io/${{ github.repository }}:sha-${{ github.sha }} --app myapp-staging
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
# --- Job 4: Deploy to production (requires manual approval) ---
deploy-production:
name: Deploy to Production
needs: deploy-staging
runs-on: ubuntu-24.04
environment: production # has required reviewers in repo settings
steps:
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --image ghcr.io/${{ github.repository }}:sha-${{ github.sha }} --app myapp-prod
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
12. Frequently Asked Questions
How do I make a workflow run only on specific file changes?
Use paths and paths-ignore filters: on: push: paths: ['src/**', 'package.json'] — the workflow only runs if a file matching the pattern changes. Use paths-ignore: ['docs/**', '*.md'] to skip docs-only changes.
How do I share data between jobs?
Jobs run on separate VMs — the filesystem is not shared. Use actions/upload-artifact in one job and actions/download-artifact in subsequent jobs. For simple values (like an image tag), use outputs on the job and access them via needs.job-name.outputs.output-name.
What is the best way to handle environment-specific deployments?
GitHub Environments are the recommended approach: create environments (staging, production) in repository Settings → Environments. Each environment can have: its own secrets, required reviewers (manual approval gate), deployment branch restrictions, and a wait timer. This gives full governance over what gets deployed where, by whom, without separate tooling.
13. Glossary
- CI (Continuous Integration)
- The practice of merging code changes frequently and verifying each integration with automated builds and tests.
- CD (Continuous Deployment)
- Automatically deploying every change that passes CI to production (or staging) without manual intervention.
- Workflow
- A YAML file in
.github/workflows/describing automated processes triggered by events in a GitHub repository. - Job
- A set of steps that run on the same runner. Multiple jobs in a workflow run in parallel by default.
- Step
- A single task within a job — either a shell command (
run) or a marketplace Action (uses). - Runner
- The server (GitHub-hosted or self-hosted) that executes workflow jobs.
- OIDC
- OpenID Connect — enables keyless cloud authentication from GitHub Actions without storing long-lived credentials.
- Environment
- A GitHub feature scoping secrets and requiring approvals for deployments to specific targets (staging, production).
14. References & Further Reading
- GitHub Actions Documentation
- GitHub Actions Marketplace
- Docker GitHub Actions Guide
- Fly.io GitHub Actions Integration
- DORA Research — DevOps Performance Metrics
Create a .github/workflows/ci.yml in your project right now with just checkout, install, and test steps. Getting tests running automatically on every push is the highest-ROI engineering improvement for most teams. Add deployment automation after CI is solid.