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:
- A baseline
ci.yamlthat lints, tests, and type-checks every PR on theubuntu-24.04default runner. - A matrix that exercises multiple language versions in parallel.
- Dependency caching that drops install time from 60-90 seconds to 5-10 seconds per job.
- OIDC-based deploys to AWS / GCP / Azure with no static credentials in the repo.
- A protected
productionenvironment with manual approval gates. - A scheduled (cron) workflow for nightly maintenance.
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
- A GitHub repository you own or admin (free tier is fine).
- Git 2.45 or newer locally.
- Node.js 22 LTS (or your language’s current LTS) for the example workflow.
- For OIDC deploys: an AWS account with IAM admin, or a GCP/Azure equivalent. Skip Step 4 if you only want CI.
- Optional:
ghCLI 2.50+ for triggering and inspecting workflows from the terminal.
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
- Re-running the full matrix on every push. Add
concurrencyto cancel superseded runs on the same ref. On a busy PR this saves 60-80% of CI minutes.concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - No dependency caching. A 90-second
npm cion every job, on every push, on every PR. Add the cache, drop it to 8 seconds. - Running everything on every change. Path filters skip jobs that do not need to run; ignore docs-only changes with
paths-ignore: ['**.md', 'docs/**'].on: pull_request: paths: ['frontend/**', '.github/workflows/frontend.yaml'] - Not pinning third-party action versions.
uses: actions/checkout@v4is fine for trust;uses: someone-else/action@mainis a supply-chain risk. Pin to a SHA for third-party actions on critical paths and use Dependabot to update them. - Default
permissions: write-all. Setpermissions: contents: readat the workflow level and grant only what each job needs (id-token: writefor OIDC,packages: writefor ghcr.io pushes). The token security docs explain why. - Hosted runner sticker shock. Watch the Actions billing dashboard until the patterns above are clean. Linux is cheapest; macOS is roughly 10x more per minute, so put iOS-only jobs on a separate workflow.
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
- GitHub Actions documentation — the canonical reference.
- OpenID Connect security hardening.
- Dependency caching docs.
- Reusable workflows docs.
- ubuntu-latest 24.04 migration tracking issue.
- Actions billing reference.
- act — run Actions locally for debugging.
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.