GitHub Actions is CI/CD built into GitHub — no separate service, 2,000 free minutes/month for private repos. Every push, PR, or tag can trigger a workflow that tests, builds, and deploys automatically.
How It Works
Workflows are YAML files in .github/workflows/. GitHub spins up a virtual machine (Ubuntu, Windows, or macOS) and runs jobs when the trigger fires.
First Workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
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: npx tsc --noEmit
- run: npm test
- run: npm run build
Triggers
on:
push:
branches: [main, 'release/*']
paths: ['src/**', 'package.json'] # only when these change
schedule:
- cron: '0 2 * * 1' # every Monday at 2am UTC
workflow_dispatch: # manual trigger from GitHub UI
inputs:
environment:
type: choice
options: [staging, production]
workflow_run: # when another workflow completes
workflows: ['CI']
types: [completed]
Job Dependencies
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
build:
needs: test # waits for test
runs-on: ubuntu-latest
steps:
- run: npm ci && npm run build
deploy:
needs: [test, build] # waits for both
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying..."
Secrets
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: npx vercel --prod --token=$VERCEL_TOKEN
Store in GitHub → Settings → Secrets and variables. Organization secrets work across all repos.
Caching
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-node-
- run: npm ci
Cache invalidates automatically when package-lock.json changes.
Matrix Builds
jobs:
test:
strategy:
matrix:
node-version: ['18', '20', '22']
os: [ubuntu-latest, macos-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
Generates 6 parallel jobs. fail-fast: false keeps all running even if one fails.
Full CI/CD: Node.js + Docker + Deploy
name: Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx prisma migrate deploy
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
- run: npm test
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- 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
deploy:
needs: build-and-push
environment: production # requires manual approval
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker stop my-app || true
docker rm my-app || true
docker run -d --name my-app --restart unless-stopped \
-p 3000:3000 -e DATABASE_URL=${{ secrets.DATABASE_URL }} \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
Useful Patterns
Skip CI
if: "!contains(github.event.head_commit.message, '[skip ci]')"
Notify on failure
- name: Notify Slack
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Deploy failed for ${{ github.repository }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Dependabot for auto-updates
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
Free Tier vs Costs
| Plan | Free minutes | Windows multiplier | macOS multiplier |
|---|---|---|---|
| Free | 2,000/month | 2x | 10x |
| Pro | 3,000/month | 2x | 10x |
Public repos: unlimited minutes. Self-hosted runners: free (you provide hardware).
Save minutes: use paths filters, cache aggressively, limit matrix size.
Full article at stacknotice.com/blog/github-actions-complete-guide-2026
Top comments (0)