GitHub Actions CI/CD pipeline workflow running automated builds and deployments
← All Articles
DevOps

Complete CI/CD Pipeline with GitHub Actions: Real Project

Why GitHub Actions Became the Default CI/CD

Jenkins requires a server. GitLab CI requires GitLab. CircleCI requires a separate account. GitHub Actions runs where your code already lives — in your GitHub repository. No extra infrastructure, no separate login, no webhook configuration.

For most teams in 2026, GitHub Actions is the first choice for CI/CD. It is free for public repositories, generous for private ones (2,000 minutes/month on free tier), and integrates with every deployment target imaginable.

This guide builds a complete pipeline from scratch using a real project — an Astro blog deployed to Cloudflare Pages. Every workflow file is tested and production-ready.

GitHub Actions workflow running automated CI/CD pipeline A complete CI/CD pipeline — from git push to production deployment in minutes

What We Are Building

The pipeline has four stages:

  1. Lint — check code formatting and syntax
  2. Build — compile the project and verify it builds cleanly
  3. Test — run any automated tests
  4. Deploy — push to Cloudflare Pages on merge to main

Every push to any branch runs stages 1-3. Only merges to main trigger stage 4 (deployment).

Project Structure

your-project/
  |- .github/
  |   |- workflows/
  |       |- ci.yml        ← runs on every push
  |       |- deploy.yml    ← runs on merge to main
  |- src/
  |- package.json

Step 1: The CI Workflow (Lint + Build + Test)

Create .github/workflows/ci.yml:

name: CI

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

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - run: npm ci

      - name: Check formatting
        run: npx prettier --check "src/**/*.{astro,ts,js,css,md}"

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - run: npm ci

      - name: Build project
        run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 1

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - run: npm ci

      - name: Run tests
        run: npm test --if-present

What Each Job Does

Lint runs first — if code formatting fails, nothing else runs. This catches issues in seconds instead of waiting for a full build.

Build runs after lint passes. It compiles the project and uploads the dist/ folder as an artifact. If the build breaks, the pipeline stops here.

Test runs after build passes. The --if-present flag means it skips gracefully if no test script exists in package.json.

The Dependency Chain

push → lint → build → test
         ↓       ↓       ↓
       (fail)  (fail)  (fail) → stops pipeline

Each job runs only if the previous one succeeded. This saves runner minutes and gives fast feedback.

Step 2: The Deploy Workflow

Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - run: npm ci

      - name: Build
        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 dist --project-name=your-project-name

Setting Up Secrets

In your GitHub repository:

  1. Go to Settings and Secrets and variables and Actions
  2. Click New repository secret
  3. Add CLOUDFLARE_API_TOKEN — get this from Cloudflare dashboard under API Tokens
  4. Add CLOUDFLARE_ACCOUNT_ID — found on your Cloudflare dashboard homepage

Never hardcode tokens in workflow files. GitHub encrypts secrets and masks them in logs automatically.

Step 3: Caching for Speed

The actions/setup-node@v4 with cache: 'npm' handles node_modules caching. But for larger projects, you can cache the build output too:

      - name: Cache build
        uses: actions/cache@v4
        with:
          path: |
            dist/
            .astro/
            node_modules/.cache
          key: build-${{ hashFiles('src/**', 'package-lock.json') }}
          restore-keys: |
            build-

Cache Impact on Build Times

Without cacheWith cacheSavings
npm ci: 25snpm ci: 3s88%
Build: 15sBuild: 4s (if cached)73%
Total: 40sTotal: 7s82%

Caching cuts pipeline time from ~40 seconds to under 10 seconds for unchanged dependencies.

Step 4: Branch Protection Rules

After setting up CI, protect the main branch:

  1. Go to Settings and Branches and Add rule
  2. Branch name pattern: main
  3. Enable:
    • Require status checks to pass before merging
    • Select the lint, build, and test checks
    • Require branches to be up to date before merging

Now nobody can merge to main (and trigger a deployment) unless all CI checks pass.

Terminal showing git workflow with CI/CD integration Branch protection ensures every deployment passes lint, build, and test checks

Step 5: Environment-Specific Deployments

For projects with staging and production environments:

name: Deploy

on:
  push:
    branches: [main, staging]

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}

    steps:
      - uses: actions/checkout@v4

      - 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 dist --project-name=${{ github.ref_name == 'main' && 'my-project' || 'my-project-staging' }}

Configure separate secrets per environment in Settings and Environments. Staging and production can have different API tokens and deployment targets.

Step 6: Notification on Failure

Add Slack or email notification when the pipeline fails:

  notify:
    name: Notify on failure
    runs-on: ubuntu-latest
    needs: [lint, build, test]
    if: failure()
    steps:
      - name: Send Slack notification
        uses: 8398a7/action-slack@v3
        with:
          status: failure
          fields: repo,message,commit,author
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

The if: failure() condition means this job only runs when something in the pipeline breaks. No noise on successful builds.

Common Patterns

Run Jobs in Parallel

If lint, build, and test are independent:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]

  build:
    runs-on: ubuntu-latest
    steps: [...]

  test:
    runs-on: ubuntu-latest
    steps: [...]

  deploy:
    needs: [lint, build, test]
    runs-on: ubuntu-latest
    steps: [...]

All three run simultaneously. Deploy waits for all three to pass. This cuts pipeline time by running jobs in parallel instead of sequentially.

Matrix Builds (Multiple Node Versions)

  test:
    strategy:
      matrix:
        node-version: [20, 22]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

Useful for libraries that need to support multiple Node.js versions.

Skip CI on Documentation Changes

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.gitignore'

No point running a full build when you only changed a README.

Manual Deployment Trigger

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

Adds a “Run workflow” button in the Actions tab with a dropdown to choose the environment.

Coding workspace showing CI/CD configuration GitHub Actions workflows live in your repo — version controlled like any other code

Debugging Failed Workflows

Check the Logs

Every step has expandable logs in the Actions tab. Click the failed step to see the exact error.

Run Locally with act

Test workflows on your machine before pushing:

brew install act
act -j build

This runs the build job locally using Docker. Catches most issues without waiting for GitHub runners.

Common Failures

ErrorCauseFix
npm ci failslock file mismatchRun npm install locally and commit package-lock.json
Secret not foundTypo in secret nameCheck Settings and Secrets — names are case-sensitive
Permission deniedMissing permissions blockAdd permissions: contents: read to the job
Cache miss every timeKey changes on every runUse hashFiles() on lock file, not source files
Deploy fails silentlyWrong API token scopeCreate a new token with Pages deployment permission

Cost and Limits

PlanMinutes/MonthConcurrent Jobs
Free2,00020
Team3,00060
Enterprise50,000180

For a blog with 10-20 deployments per month and 2-minute pipelines, the free tier is more than enough. A team of 5 engineers pushing daily stays well within limits.

Reduce Minutes Usage

  • Cache aggressively (dependencies, build output)
  • Skip CI on documentation changes
  • Run jobs in parallel (wall time vs total minutes)
  • Use paths filters to only run relevant workflows

Security Best Practices

Never hardcode secrets. Use GitHub repository secrets or environment secrets.

Pin action versions. Use actions/checkout@v4 not actions/checkout@main. Pinning prevents supply chain attacks from compromised actions.

Limit permissions. Add explicit permissions blocks. The default token has write access to everything — restrict it:

permissions:
  contents: read
  deployments: write

Review third-party actions. Before using a community action, check the source code. Prefer actions from verified publishers (GitHub, Cloudflare, AWS).

Use OIDC for cloud deployments. Instead of storing long-lived API tokens, use OpenID Connect to get temporary credentials:

permissions:
  id-token: write
  contents: read

This is more secure than storing permanent secrets.

Key Takeaways

  • GitHub Actions runs CI/CD where your code already lives — no extra infrastructure
  • Separate CI (every push) from CD (merge to main) into two workflow files
  • Cache npm dependencies and build output — cuts pipeline time by 80%
  • Branch protection rules prevent broken code from reaching production
  • Pin action versions and use repository secrets — never hardcode tokens
  • The free tier (2,000 minutes/month) covers most small-to-medium teams
  • Use act to test workflows locally before pushing

FAQ

Is GitHub Actions free?

Free for public repositories with unlimited minutes. Private repositories get 2,000 free minutes per month on the free plan, 3,000 on Team. Minutes are counted per-OS: Linux runners use 1x, macOS uses 10x, Windows uses 2x. Most pipelines use Linux.

Can I deploy to AWS, GCP, or Azure with GitHub Actions?

Yes. All three have official actions: aws-actions/configure-aws-credentials, google-github-actions/auth, and azure/login. The workflow structure is identical — only the deploy step changes. Use OIDC authentication for all three instead of storing long-lived credentials.

How do I handle database migrations in the pipeline?

Add a migration step after deployment. Use a separate job that runs npx prisma migrate deploy or your ORM’s migration command. Run it with a database connection string stored in GitHub secrets. For safety, run migrations on staging first with a manual approval gate before production.

Can GitHub Actions replace Jenkins?

For most teams, yes. GitHub Actions handles build, test, deploy, scheduled tasks, and manual triggers. Jenkins is still preferred for complex pipelines with custom plugins, on-premise requirements, or extremely high build volumes. For teams already on GitHub, Actions eliminates the need to maintain a Jenkins server.

How do I run workflows on a schedule?

Use cron syntax:

on:
  schedule:
    - cron: '0 8 * * 1-5'  # 8 AM UTC, Monday-Friday

Common uses: nightly builds, dependency update checks, scheduled deployments, and automated testing against external APIs.

Conclusion

A CI/CD pipeline is not optional in 2026 — it is the baseline. GitHub Actions makes the setup trivial: two YAML files, a few secrets, and branch protection rules. The result is automatic quality checks on every push and zero-touch deployments on every merge.

Start with the basic CI workflow (lint, build, test). Add deployment once CI is stable. Layer on caching, notifications, and environment-specific deployments as your project grows. The pipeline evolves with your project — not the other way around.

Need help setting up CI/CD pipelines for your infrastructure? View our consulting services

Read next: How to Use Claude Code to Write Terraform Modules

Written by
Jamshed Ali
Battle-tested DevOps & AWS engineering guides
Need DevOps help? →