On this page
- The model: events → workflows → jobs → steps
- A complete, correct CI workflow
- Events and triggers
- Runners
- Secrets, environment variables, and GITHUB_TOKEN
- Matrices
- Caching and artifacts
- Reusable workflows and composite actions
- Environments and approvals
- Keyless cloud deploys with OIDC
- Building and pushing container images
- Conditional execution and job dependencies
- Security and supply-chain hardening
- Debugging and optimization
- Production checklist
- Frequently asked questions
- Related resources
GitHub Actions is where most teams’ CI/CD lives, and it rewards understanding the model rather than copy-pasting YAML. The difference between a workflow that’s fast, secure, and maintainable and one that’s slow, leaky, and flaky is almost entirely in the details this guide covers: least-privilege permissions, keyless cloud auth with OIDC, caching that actually hits, and reusable workflows that stop you copying the same 80 lines into every repo.
The model: events → workflows → jobs → steps
A workflow is a YAML file in .github/workflows/. An event (a push, a PR, a schedule, a manual dispatch) triggers it. A workflow contains jobs, which run in parallel by default on runners (fresh VMs). Each job contains steps, which run sequentially and are either a shell command (run:) or an action (uses: — a reusable unit of logic).
event (push / pull_request / schedule / workflow_dispatch)
└─ workflow (.github/workflows/ci.yml)
└─ job(s) ── run in parallel on separate runners
└─ step(s) ── run in order; `run:` a command or `uses:` an action
Jobs are isolated: they don’t share a filesystem unless you pass data between them with artifacts or outputs. That isolation is what lets them parallelize — and what surprises people who expect job B to see the files job A built.
A complete, correct CI workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
# Least privilege by default: this workflow can only READ the repo.
permissions:
contents: read
# Cancel superseded runs on the same ref (saves minutes on rapid pushes).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # built-in dependency cache
- run: npm ci
- run: npm test
Every piece of that is deliberate: permissions: contents: read means a compromised dependency in this run can’t push code or open releases; concurrency cancels stale runs; setup-node’s cache: npm restores node_modules deps across runs.
Events and triggers
on:
push:
branches: [main]
paths: ['src/**', 'package.json'] # only when these change
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '0 6 * * *' # daily 06:00 UTC
workflow_dispatch: # manual "Run workflow" button
inputs:
environment:
type: choice
options: [staging, production]
Runners
- GitHub-hosted (
runs-on: ubuntu-latest/windows-latest/macos-latest) — ephemeral, patched VMs. Zero maintenance; ideal default. - Self-hosted — your own machines, for special hardware, private-network access, or cost at scale.
Secrets, environment variables, and GITHUB_TOKEN
steps:
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.API_TOKEN }} # from repo/org/environment secrets
LOG_LEVEL: info # plain (non-secret) config
Every run gets an automatic GITHUB_TOKEN whose scope is exactly your permissions: block. Grant only what a job needs, per job:
jobs:
release:
permissions:
contents: write # create a release/tag
packages: write # push to GitHub Packages
runs-on: ubuntu-latest
Matrices
Run the same job across combinations — versions, OSes, targets — in parallel.
jobs:
test:
strategy:
fail-fast: false # don't cancel siblings when one fails
matrix:
node: ['18', '20', '22']
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node }}' }
- run: npm ci && npm test
That’s six parallel jobs from ten lines. fail-fast: false lets you see all failures instead of just the first.
Caching and artifacts
Caching speeds up dependency restoration; artifacts pass build output between jobs or out to you.
# Explicit cache (setup-* actions wrap this for common ecosystems)
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt') }}
restore-keys: pip-
# Publish build output as an artifact
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
In a later job, actions/download-artifact@v4 retrieves it. This is the correct way to hand a build from a build job to a deploy job — remember, jobs don’t share a filesystem.
Reusable workflows and composite actions
Stop copy-pasting. A reusable workflow is a whole workflow other workflows call; a composite action bundles steps into a single uses:.
# .github/workflows/deploy.yml (reusable)
on:
workflow_call:
inputs:
environment: { required: true, type: string }
secrets:
token: { required: true }
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- run: ./deploy.sh --env "${{ inputs.environment }}"
# caller
jobs:
prod:
uses: ./.github/workflows/deploy.yml
with: { environment: production }
secrets: { token: ${{ secrets.DEPLOY_TOKEN }} }
Environments and approvals
environment: gates a job behind protection rules — required reviewers, wait timers, and environment-scoped secrets. This is how you make production deploys require a human click.
deploy-prod:
environment:
name: production
url: https://app.example.com
runs-on: ubuntu-latest
steps: [ ... ]
Keyless cloud deploys with OIDC
The most important security upgrade for CI/CD: stop storing long-lived cloud keys as secrets. With OIDC, GitHub issues a short-lived token that your cloud trusts, scoped to a specific repo/branch — nothing to leak, nothing to rotate.
jobs:
deploy:
permissions:
id-token: write # REQUIRED to request the OIDC token
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
- run: aws s3 sync ./dist s3://my-bucket
You configure a trust policy on the cloud side (an IAM role for AWS, a workload-identity federation for GCP, a federated credential for Azure) that permits only your repo and branch to assume it. Azure and GCP have equivalent azure/login and google-github-actions/auth flows.
Building and pushing container images
image:
permissions:
contents: read
packages: write # push to ghcr.io
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from/to: type=gha uses the Actions cache for Docker layers — often the biggest win for image-build times. Tagging by ${{ github.sha }} gives every build an immutable, traceable tag.
Conditional execution and job dependencies
jobs:
build:
runs-on: ubuntu-latest
steps: [ ... ]
deploy:
needs: build # wait for build to succeed
if: github.ref == 'refs/heads/main' # only on main
runs-on: ubuntu-latest
steps: [ ... ]
needs: builds a dependency graph; if: gates a job or step on an expression. Together they express “build always; deploy only from main after build passes.”
Security and supply-chain hardening
- Pin actions to a full commit SHA, not a tag —
uses: actions/checkout@<sha>. Tags are mutable; a compromised popular action pushed to an existing tag has caused real incidents. (Dependabot can update the pins.) - Least-privilege
permissions:on every workflow, widened per job. - OIDC instead of stored cloud keys.
- Guard
pull_request_targetand never run untrusted PR code with secrets. - Ephemeral, private-only self-hosted runners.
- Review third-party actions before adding them; each is code running with your token.
Debugging and optimization
- Re-run with debug logging: set repo secrets
ACTIONS_STEP_DEBUG: trueand re-run to get verbose step logs. workflow_dispatchlets you trigger a run manually with inputs while iterating.- Speed: precise cache keys,
concurrencycancellation,paths:filters to skip irrelevant changes, split slow suites across a matrix, and useneeds:so independent jobs run in parallel. - Flakiness: pin action versions, avoid time/network-dependent tests in CI, and set sensible
timeout-minutes:so a hung job doesn’t burn an hour.
Production checklist
permissions:set to least privilege at the workflow level, widened per job.- Cloud deploys use OIDC; no long-lived provider keys in secrets.
- Third-party actions pinned to commit SHAs; Dependabot keeps them current.
concurrencywithcancel-in-progresson CI to save minutes.- Cache keys hash the lockfile; artifacts pass build output between jobs.
- Production jobs gated by an
environment:with required reviewers. timeout-minutes:on every job;fail-fast: falsewhere you want full matrix results.- Image builds scan for vulnerabilities before push.
Frequently asked questions
Why don’t my jobs share files?
Each job runs on a fresh, isolated runner. Pass data between jobs with actions/upload-artifact / download-artifact or job outputs — not the filesystem.
How do I deploy to AWS/Azure/GCP without storing keys?
Use OIDC: grant the job id-token: write, use the provider’s official login action (configure-aws-credentials, azure/login, google-github-actions/auth), and configure a cloud-side trust policy scoped to your repo/branch. The credentials are short-lived and never stored.
What’s the difference between a reusable workflow and a composite action?
A reusable workflow (on: workflow_call) is an entire workflow with its own jobs that another workflow calls. A composite action bundles multiple steps into one uses: you drop inside a job. Use reusable workflows for whole pipelines, composite actions for repeated step sequences.
How do I make CI faster?
Precise dependency cache keys, concurrency cancellation of stale runs, paths: filters, parallelism via matrices and independent jobs, and Docker layer caching (type=gha) for image builds.
Is it safe to use pull_request_target?
Only with care. It runs with secrets and a writable token but involves untrusted fork code. Never check out and execute the PR’s code in that context; prefer plain pull_request (read-only, no secrets) whenever possible.
Why pin actions to a SHA instead of a tag? Tags are mutable — an attacker who compromises a popular action can move a tag to malicious code that then runs with your token. A full commit SHA is immutable; Dependabot can still bump it safely.
Related resources
- Academy: the GitHub AI Engineering path covers Actions, PR automation, and Copilot for DevOps in depth.
- Guide: Docker Compose for the images your pipeline builds, and DevOps Practices for the delivery patterns around CI/CD.
- Prompts: DevOps AI prompts for authoring and refactoring workflow YAML.
Continue learning
Related Core Guides that build on this one.
- Docker ComposeDocker Compose from services to production — networks, volumes, health checks, secrets, profiles and complete sample stacks you can run and adapt.
- DevOps PracticesThe practices that define modern delivery — IaC, CI/CD, GitOps, observability, SRE, progressive delivery — with when to use each, and when not to.
- Git CommandsEvery Git command a DevOps engineer needs, organized by workflow — with copyable examples and clear risk labels for the destructive ones.
- Kubernetes SecurityHarden Kubernetes for production — RBAC, Pod Security Standards, NetworkPolicy, admission control and runtime security, with secure vs. insecure YAML side by side.