Back to Articles
DevOps Feb 23, 2026 · 12 min read

Building a CI/CD Pipeline from Scratch with GitHub Actions

Daniel

Software Developer

I used to deploy manually. SSH into the server, pull the code, run the build, restart the service. It worked until it didn’t — a missed dependency broke production at 11 PM. I built a CI/CD pipeline with GitHub Actions and haven’t deployed manually since. Here is the exact setup.

What This Pipeline Does

Every push to main triggers:

  1. Lint — check code quality
  2. Type check — catch type errors
  3. Test — run the test suite
  4. Build — create production bundle
  5. Deploy — push to production

If any step fails, the pipeline stops and I get a notification.

The Complete Workflow File

Here is my .github/workflows/ci.yml:

name: CI/CD Pipeline

on:
push:
branches: [main]
pull_request:
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

type-check:
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

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 test -- --coverage
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: coverage-report
    path: coverage/

build:
needs: [lint, type-check, 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
- uses: actions/upload-artifact@v4
  with:
    name: dist
    path: dist/

deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
  with:
    name: dist
    path: dist/
- name: Deploy to production
  run: echo "Deploy step — replace with your deployment"

Breaking It Down

1. Caching Dependencies

The cache: ‘npm’ line is crucial. Without it, every job downloads all dependencies from scratch. With caching, subsequent runs are 50% faster.

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'  # This caches node_modules

2. Using npm ci Instead of npm install

npm ci is designed for CI. It reads package-lock.json and installs exact versions. No surprises, no version drift.

3. Parallel Jobs

Lint, type-check, and test run in parallel. They don’t depend on each other. This cuts total time from ~5 minutes to ~2 minutes.

jobs:
lint: ...      # Runs in parallel
type-check: ... # Runs in parallel
test: ...       # Runs in parallel
build:
needs: [lint, type-check, test]  # Waits for all three
deploy:
needs: build  # Waits for build

4. Artifact Passing

The build job uploads the dist/ folder as an artifact. The deploy job downloads it. This ensures the deploy uses the exact same build that was tested.

Adding Notifications

I want to know when the pipeline fails. GitHub sends email by default, but I also use Slack:

deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
# ... deploy steps ...

- name: Notify Slack on success
  if: success()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "✅ Deployed to production: \${{ github.sha }}"
      }
  env:
    SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}

- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "❌ Deploy failed: \${{ github.sha }}
\${{ github.actor }}"
      }
  env:
    SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}

Deployment: The Actual Step

For Cloudflare Pages, I use the official action:

deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
  with:
    name: dist
    path: dist/
- uses: cloudflare/pages-action@v1
  with:
    apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    projectName: my-app
    directory: dist/

For Vercel, it’s even simpler — just push to main and Vercel deploys automatically.

Preview Deployments for PRs

I also want preview URLs for pull requests:

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
# ... lint, test, build ...

preview:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/download-artifact@v4
  with:
    name: dist
    path: dist/
- uses: cloudflare/pages-action@v1
  with:
    apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    projectName: my-app
    directory: dist/
    branch: \${{ github.head_ref }}

Now every PR gets a live preview URL. Reviewers can see the changes before merging.

Common Pitfalls

1. Secrets not available in PRs from forks: GitHub doesn’t expose secrets to fork PRs. Use pull_request_target carefully, or require contributors to be collaborators.

2. Caching not working: Make sure the cache key matches. If you change Node versions, the cache is invalidated.

3. Flaky tests: If a test fails 1 in 10 times, your pipeline will be unreliable. Fix flaky tests before automating.

4. Long build times: Use actions/cache for anything expensive. Consider splitting large test suites across multiple runners.

Cost

GitHub Actions gives 2,000 free minutes per month for public repos. My pipeline uses ~5 minutes per run. With 20 pushes per month, that’s 100 minutes — well within the free tier.

Final Pipeline

My complete pipeline takes about 3 minutes from push to deploy. I get a Slack notification when it’s done. If something breaks, I know immediately. And I haven’t SSH’d into a server at 11 PM since.