CI/CD and GitHub Actions: Automating Pipelines from Scratch to Production
CI/CD is like "autopilot" for development teams—every commit automatically goes through a quality gate, and only code that passes is deployed to the production environment.
1. What You'll Learn
- Design a complete GitHub Actions workflow: Lint → Test → Build → Deploy
- Run tests simultaneously on Node.js 18, 20, and 22 using a parallel matrix strategy
- Automatically deploy to Vercel via
vercel-action - Write a multi-stage Dockerfile to build a 358MB standalone image
- Managing GitHub Secrets environment variables and PR Preview deployments
2. A True Story of a Technical Manager
(1) Pain Point: Manual deployment—still staring at the terminal at 3 a.m.
Bob is the Technical Lead of the TaskFlow team, which consists of five members who maintain a SaaS platform serving over 10,000 users. The release process every Friday goes like this:
"Alice merges the PR → Notifies Bob → Bob runs tests locally → Manually clicks
vercel deploy --prodafter the tests pass → Stares at the terminal for 10 minutes → Confirms the deployment was successful → Sends a message to the team"
Last week, Bob accidentally deployed an outdated version because he forgot to pull the latest code before npm run build. Five minutes later, users were unable to log in, and it took another 15 minutes to roll back the changes. Customer service received over 100 complaints.
| Pain Points | Impact |
|---|---|
| Manual Deployment | High rate of human error (30% of releases have issues) |
| No automated access control testing | Flawed code can still make it to production |
| Environment Mismatch | Runs locally, but throws an error in production |
| No PR Preview | You can't see the results before merging |
(2) GitHub Actions Solution
Bob designed three automated assembly lines:
# Every time PR → Automatic lint + test + build
# Merge into main → Automated Build + Deploy to Vercel
# Every day at dawn → Automated Build Docker Image
(3) Revenue
| Dimension | Manual Deployment | CI/CD Automation |
|---|---|---|
| Time to publish | 30 minutes | 8 minutes |
| Human error | 30% | < 1% |
| PR Review Efficiency | Code Only | Code + Preview URL |
| Deployment Frequency | Once a week | 5 times a day |
3. GitHub Actions Basics
Actions is GitHub's built-in CI/CD platform that uses YAML files to define workflows.
graph LR
A[git push] --> B[GitHub Actions]
B --> C[Events Trigger]
C --> D[Jobs Parallel/Serial]
D --> E[Steps Steps]
E --> F[Actions Market Components]
style B fill:#cce5ff
style D fill:#d4edda
| Concept | Description | Example |
|---|---|---|
| Workflow | One YAML file = One automation process | .github/workflows/test.yml |
| Event | Event that triggers the workflow | push, pull_request, schedule |
| Job | A set of steps (which may be parallel or dependent) | lint → test → deploy |
| Step | A single command or Action | npm run lint, actions/checkout |
| Runner | Execution Environment | ubuntu-latest, windows-latest |
(1) Workflow File Structure
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run lint
test:
needs: [lint]
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['18', '20', '22']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
- run: npm run test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
4. test.yml: Parallel Matrix Testing
graph TB
A[Git Push / PR] --> B[lint]
B --> C[test 18.x]
B --> D[test 20.x]
B --> E[test 22.x]
C --> F[build]
D --> F
E --> F
F --> G[Upload Build Artifacts]
style A fill:#cce5ff
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
(1) Complete test.yml
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: TypeScript type check
run: npx tsc --noEmit
- name: ESLint check
run: npm run lint
- name: Prettier check
run: npx prettier --check "src/**/*.{ts,tsx}"
unit-and-integration:
name: Unit & Integration Tests
needs: [lint]
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
node-version: ['18', '20', '22']
fail-fast: false
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Run unit tests
run: npm run test
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: unittests
name: codecov-node-${{ matrix.node-version }}
e2e:
name: E2E Tests
needs: [lint]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
build:
name: Build Check
needs: [unit-and-integration]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: next-build
path: .next/
retention-days: 1
▶ Example: Matrix Parallel Test Results
# GitHub Actions Console Output
Job: unit-and-integration (node-version: 18.x) ✓ 3m 12s
Job: unit-and-integration (node-version: 20.x) ✓ 2m 58s
Job: unit-and-integration (node-version: 22.x) ✓ 3m 05s
Summary:
✓ lint (1 job) 0m 45s
✓ unit-and-integration (3 jobs) 3m 12s
✓ e2e (1 job) 4m 30s
✓ build (1 job) 1m 20s
5. deploy.yml: Automatic deployment to Vercel
| Deployment Environment | Trigger Conditions | Target |
|---|---|---|
| Preview | Create/Update PR | Deploy to Preview Branch |
| Production | Merged into main | Production environment update |
| Staging | Release tag | Pre-release validation |
(1) Vercel Deploy Configuration
# .github/workflows/deploy.yml
name: Deploy to Vercel
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
jobs:
deploy-preview:
name: Deploy Preview
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel Preview
run: |
vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} > deployment-url.txt
echo "PREVIEW_URL=$(cat deployment-url.txt)" >> $GITHUB_ENV
- name: Comment Preview URL on PR
uses: thollander/actions-comment-pull-request@v2
with:
message: |
🚀 Preview Deployment Ready
| Environment | URL |
|:-----------|:----|
| Preview | ${{ env.PREVIEW_URL }} |
| Branch | ${{ github.head_ref }} |
_This comment is automatically updated on each push._
deploy-production:
name: Deploy Production
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [deploy-preview]
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
steps:
- uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel Production
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Notify deployment success
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Production deployment completed: ${{ github.repository }}@${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
6. Docker Image Build (Multi-stage, 358 MB)
For self-hosted deployments, you need to automatically build Docker images in CI.
(1) Multi-stage Dockerfile
# Dockerfile — Next.js 16 standalone Build an image
# Phase 1: Dependency Installation(Using a Cache Layer)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
npm cache clean --force
# Phase 2: Build(Generate .next Products)
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Phase 3: Run(Minimize Mirror ~358MB)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
(2) Docker Build Action
# .github/workflows/docker.yml
name: Docker Build & Push
on:
push:
tags: ['v*']
schedule:
- cron: '0 2 * * 0' # Early Sunday morning 2 AM
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
▶ Example: Docker image build output
# Build Log
#1 [deps 1/1] RUN npm ci --only=production
#1 DONE 15.2s
#2 [build 1/4] COPY --from=deps /app/node_modules ./node_modules
#2 DONE 0.1s
#3 [build 2/4] COPY . .
#3 DONE 0.3s
#4 [build 3/4] RUN npm run build
#4 DONE 28.5s
#5 [runner 1/6] COPY --from=build /app/public ./public
#5 DONE 0.1s
#6 Exporting layers
#6 DONE 3.2s
# Analysis of Image Size
ghcr.io/myorg/taskflow:latest 358 MB
7. Environment Variables and Secrets Management
graph TB
A[GitHub Secrets] --> B[Actions Runtime]
B --> C[Vercel Token]
B --> D[Database URL]
B --> E[API Keys]
B --> F[Slack Webhook]
C --> G[Deploy to Vercel]
D --> H[Run Test]
E --> I[Injection During Construction]
F --> J[Deployment Notice]
style A fill:#cce5ff
style B fill:#d4edda
| Environment Variable Name | Purpose | Source |
|---|---|---|
VERCEL_TOKEN |
Vercel API Authentication | GitHub Secrets |
VERCEL_ORG_ID |
Vercel Team ID | Vercel Dashboard |
VERCEL_PROJECT_ID |
Vercel Project ID | Vercel Dashboard |
DATABASE_URL |
Database Connection | GitHub Secrets |
CODECOV_TOKEN |
Upload Coverage | Codecov Website |
SLACK_WEBHOOK_URL |
Deployment Notice | Slack App Configuration |
(1) Configure GitHub Secrets
# In GitHub Repository → Settings → Secrets and variables → Actions → Add
gh secret set VERCEL_TOKEN --body "your-vercel-token"
gh secret set DATABASE_URL --body "postgresql://user:pass@host:5432/db"
gh secret set SLACK_WEBHOOK_URL --body "https://hooks.slack.com/services/..."
(2) Environmental Isolation Strategy
# Variable Overrides in Different Environments
jobs:
test:
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
NODE_ENV: test
deploy-preview:
environment: preview
env:
DATABASE_URL: ${{ secrets.PREVIEW_DATABASE_URL }}
NEXT_PUBLIC_API_URL: ${{ vars.PREVIEW_API_URL }}
deploy-production:
environment: production
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
NEXT_PUBLIC_API_URL: https://api.taskflow.io
8. PR Preview Deployment Workflow
PR Preview allows developers to preview the effects of their changes before merging.
graph TB
A[Developer Creation PR] --> B[Actions Trigger deploy-preview]
B --> C[Vercel Create Preview Deployment]
C --> D[Generate preview URL]
D --> E[Bot Comments PR Add a link]
E --> F[The reviewer is Preview Upload for verification]
F -->|Approval| G[Merge into main]
G --> H[Actions Trigger deploy-production]
style A fill:#cce5ff
style F fill:#fff3cd
style H fill:#d4edda
▶ Example: Complete PR Preview + Cleanup Workflow
# .github/workflows/preview.yml
name: PR Preview
on:
pull_request:
types: [opened, synchronize, closed]
jobs:
preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel Preview
id: deploy
run: |
npx vercel --token=${{ secrets.VERCEL_TOKEN }} \
--scope=${{ secrets.VERCEL_ORG_ID }} \
--confirm > preview-url.txt
echo "url=$(cat preview-url.txt)" >> $GITHUB_OUTPUT
- name: Comment URL
uses: actions/github-script@v7
with:
script: |
const url = '${{ steps.deploy.outputs.url }}'
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🚀 Preview deployed\n\n${url}\n\n_Commit: ${context.sha}_`
})
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Remove Vercel Preview
run: |
npx vercel remove \
taskflow-git-${GITHUB_HEAD_REF//\//-} \
--token=${{ secrets.VERCEL_TOKEN }} \
--yes --scope=${{ secrets.VERCEL_ORG_ID }} || true
9. Complete Example: Full TaskFlow CI/CD Pipeline
# .github/workflows/full-pipeline.yml
# ============================================
# TaskFlow Complete CI/CD Assembly Line
# ============================================
name: TaskFlow Full Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
release:
types: [published]
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
jobs:
# === Phase 1: Quality Access Control ===
quality-gate:
name: Quality Gate
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: TypeScript check
run: pnpm typecheck
- name: ESLint + Prettier
run: pnpm lint && pnpm format:check
- name: Unit tests with coverage
run: pnpm test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# === Phase 2: Integration Testing ===
integration:
name: Integration Tests
needs: [quality-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: npx prisma generate
- run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://taskflow:taskflow@localhost:5432/taskflow_test
- name: Integration tests
run: pnpm test:integration
env:
DATABASE_URL: postgresql://taskflow:taskflow@localhost:5432/taskflow_test
- name: E2E tests
run: npx playwright install --with-deps chromium && pnpm test:e2e
env:
DATABASE_URL: postgresql://taskflow:taskflow@localhost:5432/taskflow_test
# === Phase 3: Build ===
build:
name: Build Application
needs: [integration]
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Build Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v5
with:
context: .
load: true
tags: taskflow:ci-${{ github.sha }}
- name: Save build outputs
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
.next/
public/
package.json
# === Phase 4: Deployment ===
deploy:
name: Deploy
needs: [build]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
run: |
npx vercel deploy --prod \
--token=${{ secrets.VERCEL_TOKEN }} \
--scope=${{ secrets.VERCEL_ORG_ID }}
- name: Notify Slack
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ TaskFlow deployed to production\nCommit: ${{ github.sha }}\nBy: ${{ github.actor }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Run Lighthouse CI
run: npx lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_TOKEN }}
❓ FAQ
vercel-action or the Vercel CLI?vercel-action (amondnet/vercel-action) wraps common CLI operations, but updates may be delayed. We recommend using the Vercel CLI (npm install -g vercel) directly; using run: vercel deploy --prod --token=... in Actions offers greater flexibility and ensures you have the latest version.actions/secrets-scanning to detect accidental commits.actions/cache to cache node_modules and .next/cache; (2) Run independent jobs in parallel; (3) Use fail-fast: false to prevent a single version failure from causing the entire pipeline to be canceled; (4) Use pnpm instead of npm (for faster installation); (5) Self-host a runner (if the repository is large).📖 Summary
- GitHub Actions workflows consist of a three-tier structure: Event → Jobs → Steps. The YAML files are located under
.github/workflows/. strategy.matrixImplement parallel testing for Node 18/20/22 to verify compatibility across multiple LTS versions simultaneously- The
vercel deploy --prodcommand can be integrated into Actions to enable automated deployment; PR Preview is accessible via theactions/github-scriptcomment URL - A multi-stage Docker build (deps → build → runner) keeps the image size at ~358MB;
output: 'standalone'is a prerequisite. - GitHub Secrets securely store sensitive variables, and the
environmentfield enables environment isolation for development, preview, and production - Three Key Strategies for CI Optimization: Dependency Caching, Job Parallelization, and Using pnpm Instead of npm
📝 Exercises
-
Basic Problem (⭐): Create a
.github/workflows/ci.ymlthat contains three serial jobs—Lint, Test, and Build—and trigger it every timepushtransitions tomain. -
Advanced Exercise (⭐⭐): Set up a complete Vercel automated deployment pipeline for your project: (1) Configure
VERCEL_TOKENin GitHub Secrets; (2) Writedeploy.ymlto implement PR Preview and production deployment; (3) Verify that the Preview URL appears in the PR comments. -
Challenge (⭐⭐⭐): Implement a multi-environment CI/CD pipeline (dev → staging → production): (1) Each environment has its own database and secrets; (2) The dev branch is automatically deployed to the dev environment; (3) The main branch is automatically deployed to staging; (4) Deployment to production requires manual approval; (5) Notify a Slack channel via a webhook after a successful deployment.



