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.
A complete CI/CD pipeline — from git push to production deployment in minutes
What We Are Building
The pipeline has four stages:
- Lint — check code formatting and syntax
- Build — compile the project and verify it builds cleanly
- Test — run any automated tests
- 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:
- Go to Settings and Secrets and variables and Actions
- Click New repository secret
- Add
CLOUDFLARE_API_TOKEN— get this from Cloudflare dashboard under API Tokens - 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 cache | With cache | Savings |
|---|---|---|
| npm ci: 25s | npm ci: 3s | 88% |
| Build: 15s | Build: 4s (if cached) | 73% |
| Total: 40s | Total: 7s | 82% |
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:
- Go to Settings and Branches and Add rule
- Branch name pattern:
main - Enable:
- Require status checks to pass before merging
- Select the
lint,build, andtestchecks - Require branches to be up to date before merging
Now nobody can merge to main (and trigger a deployment) unless all CI checks pass.
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.
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
| Error | Cause | Fix |
|---|---|---|
npm ci fails | lock file mismatch | Run npm install locally and commit package-lock.json |
| Secret not found | Typo in secret name | Check Settings and Secrets — names are case-sensitive |
| Permission denied | Missing permissions block | Add permissions: contents: read to the job |
| Cache miss every time | Key changes on every run | Use hashFiles() on lock file, not source files |
| Deploy fails silently | Wrong API token scope | Create a new token with Pages deployment permission |
Cost and Limits
| Plan | Minutes/Month | Concurrent Jobs |
|---|---|---|
| Free | 2,000 | 20 |
| Team | 3,000 | 60 |
| Enterprise | 50,000 | 180 |
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
pathsfilters 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
actto 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