CI/CD Pipeline Explained: GitHub Actions Tutorial for Beginners (2026)
CI/CD (Continuous Integration / Continuous Deployment) is the practice of automatically testing and deploying your code every time you push changes. It's how professional teams ship code — no manual testing, no FTP uploads, no "works on my machine."
GitHub Actions is the most popular CI/CD platform in 2026, with 20+ million active workflows. It's built into GitHub, free for public repositories, and has the largest ecosystem of pre-built actions.
This guide takes you from zero to production CI/CD pipelines.
CI/CD Basics: What's Actually Happening
Developer pushes code → GitHub Actions runs tests → If tests pass → Auto-deploy to production
↓
If tests fail → Notify developer (don't deploy)
That's it. Every push triggers automated checks. Only good code reaches production.
Continuous Integration (CI)
Every code push automatically runs tests and checks. If something breaks, you know immediately — not in production at 3 AM.
Continuous Deployment (CD)
When CI passes, code automatically deploys to staging or production. No human intervention needed.
GitHub Actions Concepts
| Concept | What It Means | |---------|--------------| | Workflow | A YAML file defining automated processes | | Event | What triggers a workflow (push, PR, schedule, manual) | | Job | A set of steps that run on the same machine | | Step | An individual task (run a command, use an action) | | Action | Reusable unit of code (from the GitHub Marketplace) | | Runner | The machine executing your workflow | | Artifact | Files produced by a workflow (test reports, builds) |
Your First Workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Build
run: npm run build
This workflow:
- Triggers on every push to
mainordevelop, and every PR tomain - Checks out your code
- Sets up Node.js 22 with npm cache
- Installs dependencies (
npm ciis faster and stricter thannpm install) - Runs linting, tests, and build
Every push now gets automatically tested. If any step fails, you see a red ❌ on GitHub. If all pass, a green ✅.
Workflow Syntax Deep Dive
Triggers (When to Run)
on:
# Push to specific branches
push:
branches: [main, develop]
paths:
- 'src/**' # Only when src/ files change
- 'package.json'
# Pull requests
pull_request:
branches: [main]
# Scheduled (cron syntax)
schedule:
- cron: '0 2 * * *' # Every day at 2 AM UTC
# Manual trigger
workflow_dispatch:
inputs:
environment:
description: 'Deploy environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
# When another workflow completes
workflow_run:
workflows: ["Build"]
types: [completed]
Jobs (What to Run)
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30 # Kill job after 30 minutes
strategy:
matrix:
node-version: [20, 22] # Run with both versions
os: [ubuntu-latest, macos-latest] # Run on both OS
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This matrix runs your tests 4 times: Node 20 on Ubuntu, Node 20 on macOS, Node 22 on Ubuntu, Node 22 on macOS.
Job Dependencies
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
test:
needs: lint # Wait for lint to pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
needs: test # Wait for test to pass
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy from main
steps:
- run: echo "Deploying to production!"
Environment Variables and Secrets
# Define at workflow level
env:
NODE_ENV: production
jobs:
deploy:
runs-on: ubuntu-latest
env:
DEPLOY_ENV: production # Job-level env
steps:
- env:
API_KEY: ${{ secrets.API_KEY }} # From GitHub Secrets
run: |
curl -H "Authorization: Bearer $API_KEY" https://api.example.com/deploy
Setting up secrets:
- Go to your repo on GitHub
- Settings → Secrets and variables → Actions
- New repository secret
- Name:
API_KEY, Value: your secret
Secrets are encrypted and never logged. They're masked as *** in output.
Conditional Execution
steps:
- name: Only on main branch
if: github.ref == 'refs/heads/main'
run: echo "This is main"
- name: Only if previous step failed
if: failure()
run: echo "Something broke"
- name: Always run (even on failure)
if: always()
run: echo "Cleanup..."
- name: Skip on specific paths
if: |
!contains(github.event.head_commit.message, '[skip ci]')
run: npm test
Practical Examples
Node.js Full Pipeline
name: Node.js CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
# Quality checks
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run type-check
# Tests with coverage
test:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
# Build and push Docker image
build-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
myorg/app:latest
myorg/app:${{ github.sha }}
# Deploy to production
deploy:
needs: build-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # Requires manual approval if configured
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /app
docker compose pull
docker compose up -d
docker image prune -f
Python Pipeline
name: Python CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install ruff pytest pytest-cov
- name: Lint
run: ruff check .
- name: Test
run: pytest --cov=. --cov-report=xml
- name: Type check
run: pip install mypy && mypy .
Deploy to Cloudflare Pages
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy out --project-name=my-site
Deploy to AWS
name: Deploy to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- 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: Login to ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
env:
ECR_REGISTRY: ${{ steps.ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/myapp:$IMAGE_TAG .
docker push $ECR_REGISTRY/myapp:$IMAGE_TAG
- name: Update ECS service
run: |
aws ecs update-service \
--cluster my-cluster \
--service my-service \
--force-new-deployment
Caching Dependencies
Speed up workflows by caching installed dependencies:
steps:
- uses: actions/checkout@v4
# Node.js (built-in cache via setup-node)
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # Automatically caches ~/.npm
# Python
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip' # Automatically caches pip downloads
# Docker layers
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
cache-from: type=gha # Use GitHub Actions cache
cache-to: type=gha,mode=max
Proper caching reduces workflow time from 5+ minutes to under 1 minute.
Notifications
Slack Notification on Failure
steps:
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
slack-message: "❌ Deploy failed: ${{ github.run_id }}"
slack-channel: C0123456789
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
Discord Notification
steps:
- name: Notify Discord
if: always()
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
title: "Deploy ${{ job.status }}"
Reusable Workflows
Define a workflow once, reuse across repos:
# .github/workflows/reusable-ci.yml
name: Reusable CI
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '22'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
Use it from another workflow:
jobs:
ci:
uses: ./.github/workflows/reusable-ci.yml
with:
node-version: '22'
Cost Optimization
GitHub Actions free tier includes:
- 2,000 minutes/month for private repos (public repos are unlimited)
- 500MB of artifact storage
Tips to reduce usage:
- Cancel outdated runs. When you push a new commit, cancel the old run:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
- Use specific paths. Don't run CI when only docs change:
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
-
Cache everything. Dependencies, Docker layers, build output.
-
Use smaller runners.
ubuntu-latestis cheaper thanmacos-latest.
Conclusion
CI/CD isn't optional for modern development teams. It catches bugs early, automates repetitive tasks, and gives you confidence to deploy frequently.
Start simple:
- Add a basic CI workflow that runs lint and tests on every push
- Add caching to speed up builds
- Add deployment when your tests are reliable
- Add notifications so your team knows about deploys
GitHub Actions makes this all free, accessible, and powerful. The YAML syntax has a learning curve, but the patterns in this guide cover 90% of real-world use cases.
The best time to add CI/CD was when you started the project. The second best time is now.