~/tutorials/github-actions-ci-cd-tutorial-5-patterns-for-80-of-pipelines
§ POST · MAY 12, 2026 v1.0

GitHub Actions CI/CD tutorial: 5 patterns for 80% of pipelines

GitHub Actions CI/CD tutorial: the minimum viable workflow, 5 patterns (matrix, cache, OIDC, environments, cron), and the 4 mistakes that burn CI minutes.
Ryan CallowayStaff contributor
  9 min read

By Ryan Calloway. Updated May 2026.

The minimum useful GitHub Actions workflow is under 50 lines: a matrix on two language versions, a dependency cache, parallel jobs for lint and test, and a deploy job behind a protected environment. That single shape repeats across most public starter templates and is the pattern that gets upvoted in the recurring r/devops “what is your minimum GitHub Actions workflow?” thread. Actions runs CI for the majority of professional developers in the Stack Overflow 2025 Developer Survey, well ahead of Jenkins, GitLab CI, and CircleCI. Almost any tutorial on the internet is some version of the same starter file. This is that file, the five patterns that show up in 80% of real CI/CD pipelines, and the four mistakes that burn the most CI minutes per month.

What you’ll build

By the end of this tutorial you will have a production-shaped GitHub Actions setup with five reusable patterns:

The example uses Node.js 22 LTS; the patterns translate one-to-one to Python, Go, Java, or Ruby with a different setup-* action.

Prerequisites

The default runner image migrated to ubuntu-24.04 on January 17, 2025 (tracking issue #10636); every ubuntu-latest below resolves to 24.04 at the time of writing.

Step 1: the minimum viable workflow

Create .github/workflows/ci.yaml with the smallest workflow that does anything useful:

# .github/workflows/ci.yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm run lint
      - run: npm test

Commit and push. The workflow runs on every push to main and every pull request. The explicit permissions: contents: read follows the principle of least privilege — never use the default permissions: write-all; the automatic token authentication docs explain why.

That is the entire spine of CI for any Node project. Everything below is layered on top.

Step 2: matrix testing across versions

For a library that targets multiple Node majors, run them all in parallel:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: ["20", "22"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "${{ matrix.node }}"
          cache: "npm"
      - run: npm ci
      - run: npm test

Four combinations run in parallel. fail-fast: false means one failure does not cancel the others; you see all the broken combos at once. For application code one OS and one Node version is enough; for libraries published to npm, the matrix is mandatory or you get bug reports two days after release.

Step 3: dependency caching that actually saves time

The cache: "npm" on actions/setup-node@v4 handles npm automatically. For other tools, use the cache action directly. Python with pip + venv:

- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/pip
      .venv
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
    restore-keys: ${{ runner.os }}-pip-

The key is a hash of your lockfile; it changes when dependencies change. The restore-keys are fallbacks if no exact match is found. Cache hits drop install time from 60-90 seconds to 5-10 seconds on a typical project. The official caching docs walk through every common language.

For Docker layer caching on multi-platform builds, use docker/build-push-action@v6 with cache-from and cache-to pointed at the GitHub registry cache; that is the canonical pattern for image-heavy CI.

Step 4: secrets and OIDC for cloud deploys

Never commit credentials. Add them to the repo’s secrets at Settings → Secrets and variables → Actions, then reference them with ${{ secrets.NAME }}. Use repository secrets for repo-scoped values, environment secrets when only the production environment should see them.

- run: ./deploy.sh
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

For AWS, GCP, and Azure deploys, prefer OpenID Connect (OIDC) over long-lived access keys. OIDC lets a workflow assume an IAM role using a short-lived token; no static credentials in the repo.

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://my-bucket --delete

Setup is a 20-minute exercise the first time and saves a credential-rotation incident later. Recurring r/aws and r/devops threads on AWS access-key leaks all converge on the same advice: switch to OIDC. The official docs have the cloud-by-cloud setup; for AWS specifically, see “Configuring OIDC in AWS”.

Step 5: deploy job behind a manual approval

Configure environment: production in Settings → Environments with required reviewers. The deploy job pauses until a reviewer approves; this catches the 3 a.m. “wait, that PR was supposed to be a draft” mistake exactly once.

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

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    if: github.ref == 'refs/heads/main'
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE }}
          aws-region: us-east-1
      - run: ./deploy.sh

Add a separate staging environment with no reviewers for fast iteration; promote to production manually. Branch protection on main with required CI checks closes the loop.

For scheduled jobs (nightly data refresh, link checking, dependency-update PRs):

on:
  schedule:
    - cron: "17 2 * * *"   # 02:17 UTC daily

jobs:
  refresh-data:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/refresh-data.sh

Pick an offbeat minute (17, not 00) so you do not collide with everyone else’s 0 * * * * jobs; the official schedule docs warn that on-the-hour cron schedules tend to be delayed under platform load.

The directory layout that works across project types

.github/
  workflows/
    ci.yaml             # tests + lint on every PR
    deploy.yaml         # deploy on push to main, manual approval
    nightly.yaml        # scheduled jobs
  CODEOWNERS            # auto-request reviewers
  dependabot.yaml       # weekly dep updates

Three workflow files, named for what they do. Avoid one mega-workflow with eight jobs; small files are easier to read and test in isolation.

Common pitfalls

Composite actions and reusable workflows

If you find yourself copying five lines into three workflow files, extract.

Composite action at .github/actions/setup-app/action.yaml:

name: Setup app
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: "22"
        cache: "npm"
    - run: npm ci
      shell: bash

Use it from any workflow with uses: ./.github/actions/setup-app. Cleaner than copying four steps.

Reusable workflow for cross-repo sharing: define a workflow with workflow_call, call it from another repo with uses: org/repo/.github/workflows/test.yaml@v1. Useful for monorepos and multi-repo orgs with a standard CI pattern. The reusable-workflows docs have the full syntax.

FAQ

How do I trigger a workflow manually?

Add workflow_dispatch: under on:. The workflow then has a “Run workflow” button in the Actions UI; you can also trigger it via the API or with gh workflow run ci.yaml.

What is the difference between a job and a step?

A workflow has one or more jobs. A job runs on one runner. A job has one or more steps that run sequentially on that runner. Jobs run in parallel by default; use needs: to declare dependencies between them.

How do I share files between jobs?

Upload the file as an artifact in the producing job; download it in the consuming job. Artifacts persist for 90 days by default and cost storage, so set retention-days for short-lived outputs.

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

# in the next job:
- uses: actions/download-artifact@v4
  with:
    name: build
    path: dist/

How do I debug a failing workflow?

Re-run with “Re-run failed jobs” with “Enable debug logging” checked. Set ACTIONS_STEP_DEBUG: true as a repository secret for permanent verbose logs. For local repro, act runs many workflows on your laptop using Docker; not perfect coverage, but it covers about 70% of cases. For the rest, push to a branch and iterate — cheaper than fighting Docker-in-Docker on your laptop.

Should I use self-hosted runners?

Hosted runners are fine for 95% of workflows. Self-hosted is the right call when you have heavy GPU workloads (LLM evals, ML training), strict network requirements (access to internal services), CI runs that exceed the 6-hour hosted limit, or sustained volume where the per-minute math flips. The operational burden of maintaining auto-scaling runner pools, security patches, and queue management is real; only commit when one of those four reasons applies.

What is the deal with ubuntu-latest in 2026?

ubuntu-latest resolves to ubuntu-24.04 as of January 17, 2025 per runner-images issue #10636. Pin to ubuntu-24.04 explicitly if you want stable behaviour across the next ubuntu-latest migration; pin to ubuntu-latest if you want the rolling default and accept occasional breaking image changes.

How do I handle monorepos?

Path filters per workflow (paths: ['apps/web/**']) plus reusable workflows for the shared CI logic. For very large monorepos, consider Nx or Turborepo to compute affected projects and pass that into a matrix; this is the pattern Vercel and Shopify both wrote about in their CI scaling posts.

Sources and further reading

Once your CI builds and tests, the next decision is what your stack runs on locally; the Docker Compose tutorial walks through Node.js + Postgres + Redis end to end. For the language-side framework choice that will be on the receiving end of these CI runs, see the FastAPI vs Flask vs Django REST guide.

esc