
GitHub Actions in Practice: Building Your Own CI/CD Pipeline
Manual builds and deploys led to shipping bugs to production. GitHub Actions automated my pipeline and eliminated human error.

Manual builds and deploys led to shipping bugs to production. GitHub Actions automated my pipeline and eliminated human error.
How to deploy without shutting down servers. Differences between Rolling, Canary, and Blue-Green. Deep dive into Database Rollback strategies, Online Schema Changes, AWS CodeDeploy integration, and Feature Toggles.

ChatGPT answers questions. AI Agents plan, use tools, and complete tasks autonomously. Understanding this difference changes how you build with AI.

Solving server waste at dawn and crashes at lunch. Understanding Auto Scaling vs Serverless through 'Taxi Dispatch' and 'Pizza Delivery' analogies. Plus, cost-saving tips using Spot Instances.

Why your server isn't hacked. From 'Packet Filtering' checking ports/IPs to AWS Security Groups. Evolution of Firewalls.

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.
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:
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.
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:
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.
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.
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 Taskson:
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 Executionon:
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.
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.
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.
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_KEYUse 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.
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.
- 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 }}
Using similar workflows across projects creates duplication. Make them reusable.
.github/workflows/reusable-deploy.ymlname: 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 }}
GitHub Actions is free for public repos. Private repos get 2,000 minutes/month free (3,000 for Pro accounts).
Cost Reduction Tips:paths filters for relevant files onlyif conditionsdeploy:
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.
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
I've tried other CI/CD tools. Each has pros and cons.
GitHub Actions Pros:Bottom line: if you're already on GitHub, GitHub Actions has the least friction. No separate service signup needed, everything in one place.
Building a GitHub Actions pipeline is like designing a factory automation line.
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.
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.