Prologue: Friday 5:30 PM, Emergency Bug Report
Friday, 5:30 PM. I was getting ready to leave when an urgent Slack message popped up: "Payments aren't working in production." Cold sweat. I had deployed that code myself just 30 minutes ago.
It worked perfectly on my local machine. I ran the tests. But during deployment, I forgot one environment variable. It was in my .env.local but not configured on the production server. I fixed it manually in a rush, but payments were down for 15 minutes.
That evening, I made a decision: "Never manually deploying again." I started learning GitHub Actions, and now when I push code, it automatically tests, builds, and deploys. No room for human error. This post is a record of what I learned.
Aha! Moment: CI/CD Is Hiring a Robot Assistant
When I first heard about CI/CD, I thought it was complex DevOps territory. But it boiled down to this: Delegating boring, repetitive tasks to robots.
Think of a restaurant. If the chef (developer) had to wash ingredients, sharpen knives, and do dishes every time they cooked, it would be inefficient. Hire a sous chef (CI/CD) to handle repetitive tasks, and the chef can focus on cooking.
GitHub Actions is that sous chef. When you push code, it automatically:
- Runs linting (sharpening knives)
- Executes tests (checking ingredients)
- Builds (cooking)
- Deploys (serving)
Every step is automated, and if one fails, it won't proceed. You'll never serve raw ingredients.
The biggest insight I gained was this: CI/CD isn't optional—it's essential. Even when working alone. It's a safety net for your future self.
Deep Dive: Building Your GitHub Actions Pipeline
1. The Core Structure of Workflow YAML
GitHub Actions workflows are defined in .github/workflows/ as YAML files. It looked complex at first, but the structure is simple.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Deploy to Vercel
run: vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
Core structure:
- on: When to run (triggers)
- jobs: What to do (work units)
- steps: How to do it (specific commands)
Each job runs on an independent server (runner). Use needs to define dependencies and control order. In the example above, deploy only runs if test succeeds.
2. All About Triggers: When to Run Your Pipeline
Push Trigger - The Basics
on:
push:
branches: [main, develop]
paths:
- 'src/**'
- 'package.json'
Runs only on pushes to specific branches. Use paths to trigger only when certain files change. No need to run the full build if you only updated documentation.
Pull Request Trigger - Automating Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
Runs tests whenever a PR is opened or updated. Essential for team workflows. Catches issues before merging.
Schedule Trigger - Periodic Tasks
on:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM
Uses cron syntax. I run nightly dependency security checks with this. Wake up to a report.
Manual Trigger - On-Demand Execution
on:
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
Trigger from the GitHub UI with a button click. Useful for urgent hotfix deployments.
3. Matrix Builds: Testing Multiple Environments at Once
Node.js projects can behave differently across versions. To test all versions:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This runs 3 OSes × 3 Node versions = 9 jobs in parallel. Validates all environments at once. Especially useful when building libraries.
4. Caching: How I Tripled Build Speed
My first pipeline was slow. npm install took 2 minutes every time. Added caching, dropped to 30 seconds.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # This one line does it
- run: npm ci
- run: npm run build
Adding cache: 'npm' to actions/setup-node automatically caches node_modules. Also supports pnpm and yarn.
More complex caching is possible:
- name: Cache build output
uses: actions/cache@v4
with:
path: |
.next/cache
dist
key: ${{ runner.os }}-build-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-
Caching Next.js's .next/cache dramatically reduces build times.
5. Managing Secrets: Keeping API Keys Safe
You can't commit .env files to your repo. Use GitHub Secrets instead.
Add them in Settings → Secrets and variables → Actions:
VERCEL_TOKENDATABASE_URLSTRIPE_SECRET_KEY
Use in workflows:
- name: Deploy
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
vercel --token=$VERCEL_TOKEN
If printed to logs, they're automatically masked as ***. No accidental exposure.
Organization secrets are also available for sharing across multiple repos.
6. Real-World Workflow: Lint → Test → Build → Deploy
My actual production pipeline:
name: Production Pipeline
on:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run type-check
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
build:
needs: 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: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-project
directory: dist
Each stage is independent, and if one fails, it stops. If there's a lint error, tests don't even run. Saves time and money.
7. Deploying to Different Platforms
Vercel Deployment
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
vercel-args: '--prod'
AWS S3 + CloudFront
- name: Deploy to S3
uses: jakejarvis/s3-sync-action@v0.5.1
with:
args: --delete
env:
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Invalidate CloudFront
uses: chetan/invalidate-cloudfront-action@v2
env:
DISTRIBUTION: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
PATHS: '/*'
AWS_REGION: 'us-east-1'
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Cloudflare Pages (What I Currently Use)
- uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: codemapo
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
8. Reusable Workflows: Eliminating Duplicate Code
Using similar workflows across projects creates duplication. Make them reusable.
.github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
deploy_token:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Deploy to ${{ inputs.environment }}
run: ./deploy.sh ${{ inputs.environment }}
env:
TOKEN: ${{ secrets.deploy_token }}
Call from Other Workflows
jobs:
deploy-staging:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
secrets:
deploy_token: ${{ secrets.STAGING_TOKEN }}
deploy-prod:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
secrets:
deploy_token: ${{ secrets.PROD_TOKEN }}
9. Cost Considerations: Maximizing the Free Tier
GitHub Actions is free for public repos. Private repos get 2,000 minutes/month free (3,000 for Pro accounts).
Cost Reduction Tips:
- Aggressive caching - Build time equals cost
- Remove unnecessary triggers - Use
pathsfilters for relevant files only - Self-hosted runners - Use your own servers (unlimited free)
- Minimize matrices - Only necessary combinations
- Conditional execution - Skip unnecessary jobs with
ifconditions
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Only deploy on main branch pushes
In my experience, about 5 personal projects fit comfortably within the free tier.
10. Debugging: Fixing Failed Workflows
Workflows failed frequently at first. I learned to debug them.
Step 1: Read Logs Carefully Click each step in the GitHub UI to see detailed logs. Error messages are usually clear.
Step 2: Enable Debug Logging
- name: Debug info
run: |
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Actor: ${{ github.actor }}"
env
Print all environment variables to identify what went wrong.
Step 3: Local Testing with act act lets you run GitHub Actions locally.
brew install act
act -j test # Run only the test job locally
Validate before pushing. Reduces trial and error.
Step 4: Set Timeouts Infinite loops rack up costs. Set timeouts.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10 # Force kill after 10 minutes
11. Comparing with CircleCI and GitLab CI
I've tried other CI/CD tools. Each has pros and cons.
GitHub Actions Pros:
- Perfect GitHub integration (PRs, Issues, etc.)
- Thousands of actions in the Marketplace
- Intuitive YAML
- Completely free for public repos
CircleCI Pros:
- Faster execution (paid plans)
- Superior Docker layer caching
- Better for complex pipelines
GitLab CI Pros:
- Easier self-hosting
- Generous free tier (400 minutes + shared runners)
- Strong Kubernetes integration
Bottom line: if you're already on GitHub, GitHub Actions has the least friction. No separate service signup needed, everything in one place.
A Metaphor to Wrap Up
Building a GitHub Actions pipeline is like designing a factory automation line.
- Raw material intake (checkout) - Fetch the code
- Quality control (lint, test) - Filter out defects
- Assembly (build) - Create the final product
- Packaging/shipping (deploy) - Deliver to customers
If any stage has issues, it doesn't proceed. Defective products never reach customers.
It looks complex at first, but once set up, the benefits last forever. Tasks I used to do manually are now automated, letting me focus on more important problems.
Summary: What I Learned
- CI/CD is essential, not optional - Even solo dev needs this safety net for future self
- Start small - Begin with simple lint+test, expand gradually
- Caching is mandatory - Dramatically reduces build time and cost
- Choose triggers carefully - Unnecessary runs waste money
- Fail fast - If lint fails, don't run tests—save time
- Secure with secrets - Never put env vars in code
- Reusable workflows - Eliminate duplicate code, easier maintenance
- Test locally first (act) - Reduce trial and error
- Free tier is generous - Most personal projects fit within free limits
- Automation builds trust - No more fear of pushing code
After that Friday afternoon emergency bug, I never manually deploy again. GitHub Actions checks every item on the checklist for me. I can focus purely on writing code and sleep well at night, without worrying about production breaking.